Skip to main content

async / await & asyncio

Level 4: Level 4: Engineeringhard20 minasyncioasync-awaitcoroutinesconcurrency

Run many I/O tasks concurrently with coroutines and asyncio.gather.

Concurrency on one thread

A web service that fetches 50 URLs spends almost all its time waiting on the network, not computing. Threads can overlap that waiting, but you pay for context switches, GIL contention, and locks around shared state. asyncio overlaps it on a single thread: while one task waits, the loop runs another. Because a task runs uninterrupted until its next await, you rarely reach for a lock. This is the default tool for I/O-bound fan-out (many network calls or database queries) in modern Python.

Coroutines are values, not running code

async def defines a coroutine function. Calling it does not run the body; it returns a coroutine object that is suspended at the top, waiting to be driven:

import asyncio

async def fetch_one(n):
    await asyncio.sleep(0.1)   # hands control back to the loop while "waiting"
    return n * 10

coro = fetch_one(1)   # nothing has run yet; coro is a coroutine object
Check yourself
A request handler needs to write an audit record but does not need the result, so you call record_audit(event), an async def, and deliberately do not await it. What actually happens?

await is the only place a coroutine gives up control. Between awaits it runs straight through like ordinary code.

Call stack — One thread, three coroutines, control moving only at await
Step 1 / 8
event loop

A single thread. The loop holds three ready coroutines.

The stack is never deeper than one task, because only one coroutine runs at a time. What overlaps is the WAITING, not the executing. That is also why a blocking call inside a coroutine is fatal: the loop cannot take control back until an await, so nothing else on this thread can progress.

Overlapping the waiting with gather

asyncio.gather schedules many coroutines at once and waits for all of them, returning results in argument order (not finish order):

async def main():
    return await asyncio.gather(fetch_one(1), fetch_one(2), fetch_one(3))

asyncio.run(main())   # [10, 20, 30] after ~0.1s total, not 0.3s

asyncio.run(coro) is the one synchronous door into async code: it starts a fresh event loop, runs the coroutine to completion, and closes the loop.

Check yourself
Instead of gather you write: results = [await fetch_one(n) for n in range(50)]. Every fetch waits about 100 ms on the network. Roughly how long does the whole line take?

Why this sandbox uses a helper

asyncio.run refuses to start when a loop is already running and raises RuntimeError. This sandbox runs your code inside a loop, so it hands you run_coroutines(coros) instead. It drives each coroutine with coro.send(None) and reads the return value off the resulting StopIteration. That works because the sandbox fetch_one awaits nothing, so a single send finishes it. You still build real coroutines with fetch_one(n); you just pass them to run_coroutines in place of asyncio.run(asyncio.gather(*coros)).

Check yourself
Inside a coroutine that handles a request you back off before a retry with time.sleep(2), not asyncio.sleep(2). A hundred other requests are in flight on the same loop. What is the effect?

Pitfall: a coroutine you never drive

Calling fetch_one(5) and treating the result like a number does nothing useful. The body never runs, and Python warns RuntimeWarning: coroutine 'fetch_one' was never awaited. A coroutine only executes when something awaits it, gathers it, or runs it on a loop. The reverse trap is just as common: never put a blocking call (time.sleep, a synchronous DB driver, a heavy compute loop) inside a coroutine, because it freezes the whole thread and no other task can make progress.

Interview nuance: asyncio is cooperative, not preemptive. The event loop can only switch tasks at an await; it cannot interrupt running code. So gather speeds up work that spends its time awaiting real I/O, but a CPU-bound coroutine (or a stray time.sleep) starves every other task on the loop. That is the core reason asyncio scales I/O-bound fan-out yet does nothing for CPU-bound work, where you reach for processes instead.

Check yourself
An async service starts timing out under load. One endpoint does a 300 ms pure-Python transform inside its coroutine, but unrelated endpoints are timing out too. Why do the others suffer?
Worked example (Python)
# Normally: asyncio.run(asyncio.gather(fetch_one(1), fetch_one(2), fetch_one(3)))
# This sandbox is already inside an event loop, so we drive the coroutines directly:
async def fetch_one(n):
    return n * 10


def run_coroutines(coros):
    out = []
    for coro in coros:
        try:
            coro.send(None)
        except StopIteration as done:
            out.append(done.value)
    return out


print(run_coroutines(fetch_one(n) for n in [1, 2, 3]))   # [10, 20, 30]

Apply

Your turn

The task this lesson builds to.

Warm-up (one file): implement fetch_all(numbers) to build a fetch_one(n) coroutine for each number and run them all with the provided run_coroutines helper, returning the results in order.

(In a normal program you'd write asyncio.run(asyncio.gather(*coros)); this sandbox is already inside an event loop, so run_coroutines stands in for it.)

fetch_all([1, 2, 3]) is [10, 20, 30].

3 hints and 3 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Implement fetch_all(numbers) in aio/gather.py: build a fetch_one(n) coroutine for every number and run them with the read-only run_coroutines helper (imported from aio.loop), returning the results in order. Some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.