◈ python mapVol 2 · Ch 27/30
Volume 2 Python, from the metal up · chapter 27

27Async/await — an event loop over suspended frames

In Chapter 26 we gave one process many threads and watched the GIL let only one of them run Python at a time. That is real concurrency for waiting, never for computing. Now we ask the sharper question hiding underneath. If a thread that is only waiting does no work, why does it cost so much to keep around? A thread blocked on a socket holds an entire 8-megabyte call stack and a slot in the kernel scheduler. All of that just to remember one integer — the descriptor it is parked on. Stack that tax ten thousand times and the machine drowns in bookkeeping. And these are connections that, almost all the time, are doing nothing. Here's the plan. We take the single fact a waiter must remember and store it in a small object on the heap, not a kernel stack. That object is a coroutine — nothing more exotic than an ordinary stack frame refused permission to die. await is the point where such a frame pauses and hands control back. The event loop is one thread running a ready-queue, resuming whichever frame's awaited thing has just become ready. And the whole way through we keep asking the one thing that matters — how does a single thread wait on ten thousand sockets at once without any one of them blocking the rest? By the end we've traded 78 gigabytes of idle stacks for a few megabytes of parked frames. One epoll call watches over them, sleeping on every socket at once and waking only for the ones that are ready. No second thread ever runs. There is just the machine remembering exactly where each waiter stopped, and picking it back up the instant its byte lands.

iolinked · chapter 27 — the checkpoints6 steps
$ sections covered in Async/await — an event loop over suspended frames
01The need: a stack per waiter is wasteful
02A coroutine is a parked frame
03await is a chosen yield point
04The event loop is one thread and a ready-queue
05One syscall watches all the sockets
06The sharp edge: one blocking call freezes everything

01The need: a stack per waiter is wasteful

Let's start with a job concrete enough to feel. You're building a chat server, and the requirement is brutally simple. Hold ten thousand connections open at once. Not ten thousand messages a second — ten thousand sockets. Each one is a person who has connected and is now mostly just… there. Typing a little, reading a little, idle for whole seconds between keystrokes. Every one of those connections is a file descriptor, a small integer the kernel hands you to name one open socket. It is waiting for its next byte, and your only job is to be ready the instant that byte lands.

Reach for the tool the last chapter handed you and the answer looks obvious: one thread per connection. Each thread calls os.read(fd) and the read blocks — the thread is now literally sitting there, parked by the kernel, doing nothing until a byte shows up to wake it. It's clean and linear: every connection reads like its own little synchronous story. Watch what happens when you turn up the dial. You test it with fifty clients and it purrs. You push it to ten thousand and the machine falls over: memory balloons, the CPU pins itself doing nothing you asked for, and latency turns to porridge. The model didn't just slow down, it collapsed. To replace it you first have to know exactly what one blocked thread costs — counted in bytes and in microseconds.

Start with memory. Every OS thread owns a full call stack. On Linux the default reservation is 8 MiB of virtual address space per pthread, and you can read it with ulimit -s. Only the pages you actually touch get backed by physical RAM. So a thread with a shallow call chain might genuinely use just tens of kilobytes. But even then, ten thousand of them is hundreds of megabytes spent on threads whose entire occupation is holding still. And the reserved 8 MiB still costs you address space and page-table entries. Do the arithmetic and it stops being abstract.

stack_tax.pypython
# what 10,000 thread stacks *reserve*, before a single byte is read
waiters      = 10_000
stack_mib    = 8                 # Linux default: ulimit -s -> 8192 KiB
reserved_gib = waiters * stack_mib / 1024
print(f"reserved address space: {reserved_gib:.0f} GiB")
# reserved address space: 78 GiB

# the same 10,000 waiters as suspended heap objects instead
coro_bytes = 416                 # a parked coroutine + its frame, order-of-magnitude
heap_mib   = waiters * coro_bytes / 1024 / 1024
print(f"heap for coroutines:    {heap_mib:.1f} MiB")
# heap for coroutines:    4.0 MiB

Now the CPU. Every thread is an entry in the kernel's run queue, and the scheduler must consider each one. Every time it switches from thread A to thread B it performs a context switch. It saves A's registers, loads B's, and swaps the stack pointer. Then comes the expensive part: it poisons the caches the CPU had warmed up. The new thread's code and data aren't in L1 or L2, and its address translations aren't in the TLB. So it stalls, fetching them back from slower memory. A single switch is on the order of one to two microseconds of pure overhead. With thousands of threads waking on scattered I/O, the machine spends more of its day switching than working. This is the classic thundering herd, where a burst of readiness wakes hundreds of threads that all fight for the same few cores.

And there is a third cost you already met: the GIL. CPython threads cannot run Python bytecode in parallel — only one holds the interpreter lock at a time. So a thread-per-connection design buys you concurrency of waiting, never concurrency of computing. You are paying the full price of OS threads and getting only the cheap half of what threads are for.

Here is the observation that cracks the whole problem open. A thread blocked in read() holds an entire call stack and a scheduler slot to remember exactly one fact: "I am waiting on file descriptor 42." That is a preposterous amount of machinery to represent one integer of waiting. The fix writes itself the moment you say it out loud. Don't give each waiter a kernel stack. Give it a small object on the heap holding just that fact, and use one thread to watch all the descriptors at once. Thousands of cheap, mostly-idle waiters is the pressure. Every design decision in the rest of this chapter is forced by it.

Same 10,000 waiters, two ways to hold them thread per waiter one OS thread + one 8 MiB stack each 8 MiB most greyed — idle, blocked on 1 fd context switch ~1µs fat kernel run-queue async one thread, one stack, 10,000 tiny frames 1 stack fd=42 fd=17 each box = one coroutine: fd + locals only 78 GB of reserved stacks vs a few MB of frames — the waiting is identical; only the bookkeeping differs.
Fig — A blocked waiter costs an 8 MiB thread stack or a tiny heap frame — async trades 78 GB of idle stacks for a few MB on one thread.
THE ONE IDEA TO CARRY FORWARD
A blocked thread spends an 8 MiB stack and a scheduler slot to remember a single number. Async is the design that stores that same number cheaply — one small heap object per waiter, one thread watching every descriptor. The whole chapter is that trade, spelled out mechanism by mechanism.

✗ The myth

"Threads are lightweight — I can just spin up one per connection and let the OS sort it out."

✓ The reality

Each thread reserves megabytes of stack and a run-queue slot, and every wake-up costs a cache-flushing context switch. Fine at fifty connections; ruinous at ten thousand, where the machine spends its cycles switching between threads that are all just waiting.

Wait — if the stacks are mostly untouched, why does the memory actually hurt? Two reasons the ulimit number hides. Reserved address space still consumes page-table entries and VMA bookkeeping in the kernel, and the pages you do touch never get released back while the thread lives. A thread that once recursed a few frames deep keeps those physical pages resident for its whole idle life — you pay for the deepest moment it ever had, forever.

So the cheap waiter has to be an object on the heap that remembers "where I was." You already know what remembers where a function was — a stack frame. The next section is the whole trick: a coroutine is that frame, kept alive instead of destroyed. →

02A coroutine is a parked frame

The last section demanded a cheap waiter: a small object on the heap that remembers one thing — where I was, and what I was holding. Now make it concrete. What object is that? You already know the answer from the functions chapter. You just haven't been told it can outlive its call. When you call a normal function, Python pushes a stack frame. That block holds the call's local variables, its temporary value stack, and f_lasti, the offset of the last bytecode instruction it ran — which is precisely "where I am." On return, that frame is popped and freed, and the locals vanish. A coroutine is that identical frame, refused permission to die.

Here is the pivot, and it is smaller than it sounds. An async def does not compile to a normal function. The compiler sets a flag, CO_COROUTINE, on the code object, and that flag changes what calling it does. Call an ordinary function and its body runs. Call a coroutine function and zero of its body runs. Instead, Python allocates a PyCoroObject on the heap and hands it straight back to you. The code you wrote hasn't executed at all yet. Prove it to yourself:

what_you_get_back.pypython
async def greet(name):
    print("running now")      # does NOT print on the call below
    reply = await ask(name)
    return reply

c = greet("Ada")   # body has NOT run — no output yet
print(c)             # <coroutine object greet at 0x7f2a...>
print(type(c).__name__)  # coroutine
print(c.cr_frame.f_lasti)  # -1  (not started; f_lasti is the resume offset)

You called greet("Ada") and got back not a greeting but an object — a paused function you can hold in a variable, pass around, or drop. Inside it sits its frame (cr_frame), and that frame owns the locals array, the value stack, and f_lasti. It also carries a state, the coroutine's whole life story in four words. It is CREATED when freshly built, SUSPENDED at each await, EXECUTING while its bytecode is actually running, and CLOSED when it has returned or been finalized. Because the frame is now a heap object, it is reference-counted like everything else. As long as your variable c points at the coroutine, the frame stays alive — every local, the value stack, the resume offset. When the refcount hits zero, the frame is finalized. It lives exactly as long as the object does, no longer.

How do you make it run, then resume it? With the low-level driver underneath everything else in this chapter: send. A fresh coroutine is started with c.send(None), which resumes the frame at f_lasti, runs bytecode until the next suspension point, and returns control to you. Call send again and it picks up from exactly that offset — locals intact, mid-flight. When the coroutine finally returns, its return value is delivered to you wrapped in a StopIteration:

drive_by_hand.pypython
async def add_later():
    x = 3                 # a local that must survive the pause
    y = await pending      # suspends here; frame is parked on the heap
    return x + y

c = add_later()
c.send(None)            # runs x=3, hits await, SUSPENDS — control returns to us
print(c.cr_frame.f_locals)  # {'x': 3}  — x is frozen in place, mid-function
try:
    c.send(40)         # resumes: y becomes 40, runs return 43
except StopIteration as stop:
    print(stop.value)  # 43  — the return value rides out on StopIteration

Sit with the middle line. After the first send, the function is stopped inside its own body. x equals 3, sitting in a frame on the heap, with no thread of any kind holding it there. A blocked thread parks that same kind of state — a few locals and a resume point — inside an 8 MiB kernel stack and a scheduler slot. A suspended coroutine parks the identical state in a 400-byte object. That is the entire cost collapse from section 1, made real: suspension without a thread. There is no kernel involvement and no run-queue entry. There is just a Python object on the heap that happens to remember where it stopped.

One coroutine = a paused frame on the heap caller namespace c refcount 1 PyCoroObject ← keeps frame alive frame locals { x = 3, conn = fd42 } value-stack [ … ] f_lasti resume here → state: CREATED SUSPENDED CLOSED SUSPENDED ⇆ EXECUTING as the loop drives it function body (bytecode) LOAD_FAST conn SEND / YIELD_VALUE RESUME STORE_FAST line f_lasti points at ONE offset
Fig — A coroutine is a heap object wrapping a frame: its locals and value-stack survive the pause, and f_lasti records the exact line to resume.
SYNTAX · async def — the whole skeleton, in eight linescalling a coroutine function runs nothing at all; await runs it, and asyncio.run starts the one loop
import asyncio async def greet(name): -- a COROUTINE FUNCTION. an ordinary def, one word in front. await asyncio.sleep(0.5) -- the pause point. control goes back to the loop here. return f"hello, {name}" -- an ordinary return. await hands you this value. async def main(): -- one async front door for the whole program print(await greet("Ada")) -- await RUNS it, waits, and takes the value asyncio.run(main()) -- the ONLY sync line. builds a loop, runs main, closes it. # the two lines everyone writes wrong on day one c = greet("Ada") -- builds a coroutine OBJECT. runs ZERO lines of the body. await greet("Ada") -- SyntaxError out here. await needs an async def around it.
async defOne keyword sets CO_COROUTINE on the code object. Same body, same defdifferent call behaviour.
calling itAllocates a PyCoroObject on the heap and hands it back. Zero lines of the body run, so nothing prints.
awaitThe only thing that makes a coroutine's body actually execute. It drives the object and gives you its return value.
asyncio.sleep(0.5)A timer Future the loop can park on. Until you have a real async client, it is your stand-in for I/O.
async def main()You need one. await is illegal outside an async def, so the program needs an async door to step through.
asyncio.run(...)Creates a loop, drives that coroutine to completion, then closes the loop. One call, at the top, once.
the RuntimeWarningcoroutine 'greet' was never awaited is Python's receipt for work you built and then never drove.
you type
# ---------- skeleton.py ----------
import asyncio


async def greet(name):                 # async def -> a COROUTINE FUNCTION
    print("body running now")
    await asyncio.sleep(0.1)           # the pause point: control goes to the loop
    return f"hello, {name}"


async def main():                      # one async front door
    line = await greet("Ada")          # await RUNS it, and waits for the value
    print(line)


c = greet("Bob")                       # NOT a call in the usual sense
print("what you get :", c)
print("its type     :", type(c).__name__)
print("did it print?: no -- body has not run")

asyncio.run(main())                    # the ONE sync line that starts the loop


# ---------- oops.py ----------          three separate files: each one dies alone
print(greet("Ada") + "!")              # a coroutine is not its result

# ---------- oops2.py ----------
print(await greet("Ada"))              # await at module level

# ---------- oops3.py ----------
def report():
    return await greet("Ada")          # await inside a plain def
you see
$ python skeleton.py
what you get : <coroutine object greet at 0x00000244D83D1480>
its type     : coroutine
did it print?: no -- body has not run
body running now
hello, Ada
sys:1: RuntimeWarning: coroutine 'greet' was never awaited

$ python oops.py                       # last line of the traceback shown
TypeError: unsupported operand type(s) for +: 'coroutine' and 'str'

$ python oops2.py
SyntaxError: 'await' outside function

$ python oops3.py
SyntaxError: 'await' outside async function
where beginners trip
  • greet("Ada") on a line by itself does nothing at all. The warning is the only sign, and it fires when the object is collected — here, at exit.
  • await outside an async def is a SyntaxError, refused at compile time. There is no try that catches it.
  • A coroutine is not its result. Add one, print one, index one, and you are handling the object, never the value.
  • asyncio.run is a top-level call. Inside a running loop it raises RuntimeError: asyncio.run() cannot be called from a running event loop.
  • The hex address in <coroutine object greet at 0x...> is a memory address. Yours differs on every single run.
  • On Windows the loop is a ProactorEventLoop and the system timer ticks about every 15.6 ms, so each asyncio.sleep overshoots by roughly that much.
FORGET TO AWAIT IT AND THE WORK NEVER HAPPENS
Because calling greet("Ada") runs none of the body, writing greet("Ada") on a line by itself does nothing — it builds a coroutine and drops it. Python even warns you: RuntimeWarning: coroutine 'greet' was never awaited. The body only executes when something drives it with send — which, in real code, is the event loop.
Wait — del a coroutine while it is suspended and its frame is finalized on the spot — the locals holding open a file or a lock are released, and if it was paused at an await, Python throws GeneratorExit into it so its finally blocks can run. The frame really does live and die with the object. Hold the reference and the pause persists indefinitely; drop it and the pause is over.

Driving a frame by hand with send is not a program. And notice we glossed the crucial line: what did await pending actually do, and what value came back out when the frame suspended? That returned value is how a coroutine tells the world what it is waiting for. →

03await is a chosen yield point

You can pause a frame by hand now, but two questions are still open. They are the two that make async a system rather than a party trick. First: what does await actually compile to? Second, and this is the one that matters: what value comes back out when a coroutine suspends? That value is the coroutine's entire vocabulary for talking to the outside world. It is how a parked frame says, "here is the specific thing I am waiting for. Resume me when it's ready." Get this and the event loop in the next section becomes obvious.

Start with the mechanism. await x requires x to be awaitable, which means type(x) defines __await__. Under the hood, await calls x.__await__() to get an iterator and then drives that iterator. And here is the load-bearing fact: whatever that iterator yields, await propagates straight up and out of the current coroutine. Coroutines and generators share one suspend/resume engine. A suspension is literally a yield. It travels up through every awaiting frame until it exits the top coroutine as the return value of send. So "what does the driver see when a coroutine awaits?" has a precise answer. It sees the object that reached the bottom of the await chain and yielded.

In asyncio, that object is a Future, a placeholder for a result that isn't ready yet. A leaf await, like waiting on a socket, ultimately runs yield self from a Future's __await__, guarded by "only if I'm not already done." That Future object surfaces out of the coroutine as a message to the driver: "park me, and resume me when this Future is resolved." You can build the smallest possible version by hand and watch the token travel:

await_is_yield.pypython
class WaitFor:
    def __init__(self, token):
        self.token = token
    def __await__(self):
        value = yield self.token   # THIS is the suspend; token exits the coroutine
        return value              # the resume value becomes the value of `await`

async def job():
    got = await WaitFor("need: fd42")
    return f"resumed with {got}"

c = job()
out = c.send(None)     # out == 'need: fd42'  — the token surfaced to us, the driver
try:
    c.send("ok")          # 'ok' threads back down to become `got`
except StopIteration as s:
    print(s.value)  # resumed with ok

The token you yielded came out of send. The value you sent back in became the result of the await. That round trip — object out, result in — is the whole protocol the event loop speaks. Now a subtlety that trips up nearly everyone: awaiting another coroutine does not touch the loop. When you write await handler(), you are not yielding to any driver. You are splicing handler's frames onto the same suspend chain. It becomes one taller stack of paused frames. Only when execution reaches a leaf — a Future that runs yield self — does anything actually surface to the loop. Coroutines awaiting coroutines build the tower. A Future at the bottom is the only thing that yields off the top of it.

await passes control up; only a Future reaches the loop loop.send() ← main() await handler() await read_line() await Future (leaf) the yield travels straight up Future surfaces here, as send()’s return await coro splice its frames onto this stack — loop never notices, no yield leaves await Future nothing to run yet — yield up to the loop; park until set_result between two awaits on the timeline: await await runs uninterrupted — no preemption here
Fig — Awaiting a coroutine just splices frames onto the stack; awaiting a leaf Future yields all the way up to the loop — and between two awaits the code runs uninterrupted.
SYNTAX · async with and async for — the same protocols, awaitedChapter 15's with and every for you have written, given awaitable versions of their dunder methods
class Connection: async def __aenter__(self): -- with's __enter__, made awaitable await handshake() -- setup that WAITS: TCP, TLS, a login return self async def __aexit__(self, *exc): -- with's __exit__, made awaitable await self.flush() -- teardown that WAITS. runs on errors too. async def rows(self): -- an ASYNC GENERATOR: await, then yield while True: yield await self.read() async with Connection() as conn: -- awaits __aenter__ now, __aexit__ on the way out async for row in conn.rows(): -- awaits __anext__ once per item print(row) # the shape you will meet in a real library (aiohttp -- a pip install, not run here) async with aiohttp.ClientSession() as session: async with session.get(url) as response: body = await response.text()
__aenter__ / __aexit__Chapter 15's context-manager pair with an a in front. async with awaits both, so setup and teardown can wait on a socket.
async withThe same guarantee as with: __aexit__ runs on the way out, exception or not. You just get to await inside it.
__aiter__ / __anext__The async iterator protocol. __anext__ returns an awaitable and raises StopAsyncIteration when the stream ends.
async generatorasync def with a yield in it. It can await between items, so each item may come off the wire as it lands.
where they are legalOnly inside an async def. Outside one they are a SyntaxError, exactly like a bare await.
what it buys youA connection whose opening and closing are themselves waits. Plain with cannot await, so it cannot express that.
the honest deferralReal clients — aiohttp, httpx, asyncpg — are built from exactly these two. We are learning the protocol, not the library.
you type
# ---------- awith.py ----------
import asyncio


class Connection:                       # ch15's with, one dunder pair later
    async def __aenter__(self):         # awaitable setup -- a real handshake takes time
        print("  opening  (await: TCP + TLS)")
        await asyncio.sleep(0.2)
        return self
    async def __aexit__(self, *exc):    # awaitable teardown -- runs even on an error
        await asyncio.sleep(0.1)
        print("  closed   (await: flush + FIN)")
    async def rows(self):               # an async GENERATOR: yields between awaits
        for n in (1, 2, 3):
            await asyncio.sleep(0.1)    # each row arrives from the network
            yield f"row {n}"


async def main():
    async with Connection() as conn:    # __aenter__ awaited, __aexit__ awaited
        async for row in conn.rows():   # __anext__ awaited once per item
            print("  got     ", row)
    print("done")


asyncio.run(main())


# ---------- two ways to get the keyword wrong (separate files) ----------
with Connection() as conn: ...          # plain with, on an async manager
async for x in [1, 2, 3]: ...           # async for, on a plain list
you see
$ python awith.py
  opening  (await: TCP + TLS)
  got      row 1
  got      row 2
  got      row 3
  closed   (await: flush + FIN)
done

$ python wrong_with.py
TypeError: 'Connection' object does not support the context manager protocol

$ python wrong_for.py
TypeError: 'async for' requires an object with __aiter__ method, got list
where beginners trip
  • Plain with on an async manager: TypeError: 'Connection' object does not support the context manager protocol. It looked for __enter__, which is not there.
  • async for over a list fails too. A list is iterable; it is not async-iterable, and the two protocols never overlap.
  • async for is sequential, not concurrent. It awaits one item at a time — reach for gather when you want overlap.
  • An async generator may not return a value: SyntaxError: 'return' with value in async generator. A bare return is fine.
  • Forget the async on with and the error points at the with line, not at the awaits inside the block.
  • None of this starts a loop. Every example here still needs its asyncio.run at the top.

And here is the conceptual payoff, the reason this design has a different feel than threads. Control only ever leaves a coroutine at an await. Between two awaits, the coroutine runs to completion with nothing able to interrupt it. There is no timer, no preemption, no other coroutine sneaking in. The coroutine names its own pause points. That is the deal async makes with you. In exchange for choosing where you yield, you get to treat every stretch of code between two awaits as atomic. The torn reads and lost updates that haunted the threading chapter simply cannot happen there, because no second coroutine can observe your half-finished state. The bill for that gift comes due in the last section: you are now responsible for awaiting often enough.

THE ONE IDEA TO CARRY FORWARD
await is a compiled yield. Awaiting a coroutine splices frames onto one suspend chain and touches no scheduler; only a leaf Future yields off the top, surfacing to the loop as "resume me when this resolves." And because you pick the yield points, the code between them is atomic — race-free by construction.
Wait — run dis(job) on any async def and you'll see the machinery bare: GET_AWAITABLE, then LOAD_CONST None and a SEND / YIELD_VALUE pair wrapped in a resume loop around each await. There is no secret await opcode — it is the generator's own send/yield instructions, arranged to drive an awaitable. The keyword is sugar; the engine underneath is the one you already met making generators lazy.

A coroutine suspends and yields a Future; you've been resuming it by hand. That doesn't scale to ten thousand. Next: the thing that does the resuming for you — and it is nothing more than a while-loop with a queue. →

04The event loop is one thread and a ready-queue

You now have a coroutine that suspends and hands you a Future, and you have been the driver, calling send by hand. That does not survive contact with ten thousand connections. You need the thing that drives automatically, and you need it demystified. Because "the event loop" sounds like a runtime cathedral you could never build. It isn't. It is a while loop over a queue, and by the end of this section you could write it yourself. Once you see that, asyncio stops being a black box. Concurrency-without-threads becomes plainly mechanical.

At its core, asyncio's loop is this: while running, run every callback currently in a FIFO ready-queue. The queue, loop._ready, is a collections.deque. Each entry is a Handle, a plain "call this function with these args" record produced by loop.call_soon(fn, *args). That deque, one thread, and one loop is the entire engine. No coroutine understands it. But a coroutine isn't a callback, so what bridges the two? The Task.

asyncio.create_task(coro) wraps a coroutine in a Task and immediately schedules the Task's __step method with call_soon. When __step runs, it calls coro.send(None), driving the coroutine forward until it yields a Future. Then the Task makes the move that everything hinges on. It does not spin, poll, or re-queue itself. It registers a callback on that Future — future.add_done_callback(self.__step) — and returns. Now the Task is off the ready-queue entirely. It is asleep, remembered only by the Future it is waiting on. The whole thing is short enough to hold in your head:

toy_loop.pypython
from collections import deque

class Loop:
    def __init__(self):        self._ready = deque()
    def call_soon(self, fn, *a): self._ready.append((fn, a))
    def run(self):
        while self._ready:               # the entire engine, right here
            fn, a = self._ready.popleft()
            fn(*a)

class Future:
    def __init__(self, loop):
        self.loop, self.done, self._cbs = loop, False, []
    def add_done_callback(self, cb):
        self._cbs.append(cb) if not self.done else self.loop.call_soon(cb, self)
    def set_result(self, value):
        self.result, self.done = value, True
        for cb in self._cbs:            # resolving wakes the parked Task
            self.loop.call_soon(cb, self)

class Task:
    def __init__(self, coro, loop):
        self.coro, self.loop = coro, loop
        loop.call_soon(self.step)      # schedule the first step
    def step(self, *_):
        try:
            fut = self.coro.send(None)  # run to the next await -> a Future
        except StopIteration:
            return                    # coroutine finished; Task is done
        fut.add_done_callback(self.step) # park on it; re-queue when it resolves

Trace one Task's life through that code and you get a clean cycle. It starts on the ready-queue. The loop pops it and runs step, which drives the coroutine forward until it yields a Future. The Task registers step as that Future's done-callback and vanishes. Time passes; other Tasks run. Eventually something calls set_result on the Future, which walks its done-callbacks and call_soons each one — putting step back on the ready-queue. On the next turn the loop pops it, step calls send again, and the coroutine resumes exactly where it suspended. Ready-queue → run to await → parked on a Future → Future resolves → back on ready-queue. Every coroutine in the system rides that loop.

SYNTAX · asyncio.gather — put N coroutines in flight at oncethree waits of 1.5 s, 0.2 s and 0.8 s finish in 1.52 s, not 2.50 s — and the results come back in the order you submitted them
results = await asyncio.gather(coro1, coro2, coro3) -- one await, three in flight results = await asyncio.gather(*[fetch(u) for u in urls]) -- the * form, for a list # what comes back results -- a LIST, in SUBMISSION order. never completion order. results[0] -- coro1's return value, however slow coro1 was # the clock wall time -- max(waits), not sum(waits). that is the whole win. # when one of them raises await asyncio.gather(a, b, c) -- 1st exception propagates NOW await asyncio.gather(a, b, c, return_exceptions=True) -- exceptions arrive as values
what it doesWraps every argument in a Task, schedules them all, and waits for the last one. The clock reads max, not sum.
the *gather takes coroutines as separate arguments, so a list has to be unpacked: gather(*coros).
the return valueOne list, in submission order. Position decides where a result lands; speed never does.
what it is notNot parallelism. One thread, one core, interleaved at awaits — so CPU-bound coroutines gain exactly nothing.
one raisesBy default the first exception propagates straight out of the gather, while its siblings keep running underneath.
return_exceptions=TrueEvery slot gets filled: a value where it worked, the exception object where it did not. Nothing is raised at you.
no cancellationA failed gather does not cancel the others. Watch left and right print 0.3 s after the error.
you type
# ---------- gather_final.py ----------
import asyncio, time


async def job(name, seconds):
    await asyncio.sleep(seconds)
    print(f"  finished {name} after {seconds}s")
    return name.upper()


async def boom():
    await asyncio.sleep(0.1)
    raise ValueError("the middle one failed")


async def main():
    t0 = time.perf_counter()
    results = await asyncio.gather(
        job("slow", 1.5),              # submitted 1st, finishes LAST
        job("quick", 0.2),             # submitted 2nd, finishes FIRST
        job("middle", 0.8),
    )
    print("results     :", results)
    print("wall clock  : %.2fs" % (time.perf_counter() - t0))
    print("sum of waits: %.2fs" % (1.5 + 0.2 + 0.8))
    print()

    try:
        await asyncio.gather(job("left", 0.4), boom(), job("right", 0.4))
    except ValueError as exc:
        print("gather raised:", exc)
    await asyncio.sleep(0.5)           # the siblings were NOT cancelled -- watch
    print()

    out = await asyncio.gather(job("solo", 0.1), boom(), return_exceptions=True)
    print("with the flag:", out)


asyncio.run(main())
you see
$ python gather_final.py
  finished quick after 0.2s
  finished middle after 0.8s
  finished slow after 1.5s
results     : ['SLOW', 'QUICK', 'MIDDLE']
wall clock  : 1.52s
sum of waits: 2.50s

gather raised: the middle one failed
  finished left after 0.4s
  finished right after 0.4s

  finished solo after 0.1s
with the flag: ['SOLO', ValueError('the middle one failed')]
where beginners trip
  • Read the results line twice. slow was submitted first and finished last — and it is still in slot 0.
  • The finish prints are in completion order; the returned list is in submission order. Those are two different orderings, both correct.
  • Passing a list without the star gives TypeError: unhashable type: 'list' — an unhelpful message for a missing *.
  • 1.52 s, not 1.50 s. Windows' ~15.6 ms timer tick means every asyncio.sleep lands a hair late.
  • Gathering coroutines that never await buys nothing. With no yield point they run one after another anyway.
  • The exception arrived at 0.1 s but left and right still finished. Orphaned Tasks keep going until something cancels them.
One loop turn, on one thread ready-queue (deque) Task handles pop & run one step coro runs → hits await yields a Future Future add_done_ callback(step) Task parked (greyed) hanging off the Future later: Future.set_result → call_soon → back onto ready-queue one thread one deque, no locks counter: ready 3 / parked 997 — the loop only ever runs what is ready; parked Tasks cost nothing.
Fig — Each turn pops a ready Task, runs it to its next await, parks it on a Future via a done-callback, and set_result later drops it back onto the one ready-queue.
TYPE THIS · save it, then run itthree_downloads.py — the same three waits, one after another (3.68 s) and all at once (1.39 s)
# three_downloads.py -- the same three "downloads", one after another vs all at once
import asyncio
import random
import time

URLS = ["/tracks.json", "/artists.json", "/covers.json"]

random.seed(27)                                  # same jitter for you and for me
LATENCY = {url: round(random.uniform(0.6, 1.4), 2) for url in URLS}


async def download(url):
    """Stand-in for a real request: asyncio.sleep is a timer the loop can park on."""
    print(f"    -> GET  {url}   ({LATENCY[url]}s of network)")
    await asyncio.sleep(LATENCY[url])            # a real client would await a socket
    print(f"    <- 200  {url}")
    return f"{url} ok"


async def one_after_another():
    results = []
    for url in URLS:
        results.append(await download(url))      # each await finishes before the next
    return results


async def all_at_once():
    return await asyncio.gather(*(download(url) for url in URLS))


async def timed(label, coro):
    print(label)
    start = time.perf_counter()
    results = await coro
    elapsed = time.perf_counter() - start
    print(f"  {label:<16} {elapsed:.2f}s   {results}")
    return elapsed


async def main():
    print("latencies      :", LATENCY)
    print("sum of waits   : %.2fs" % sum(LATENCY.values()))
    print("slowest single : %.2fs\n" % max(LATENCY.values()))

    serial = await timed("sequential", one_after_another())
    print()
    parallel = await timed("gather", all_at_once())

    print("\nspeed-up       : %.2fx" % (serial / parallel))


asyncio.run(main())
$ python three_downloads.py
latencies      : {'/tracks.json': 1.12, '/artists.json': 1.16, '/covers.json': 1.37}
sum of waits   : 3.65s
slowest single : 1.37s

sequential
    -> GET  /tracks.json   (1.12s of network)
    <- 200  /tracks.json
    -> GET  /artists.json   (1.16s of network)
    <- 200  /artists.json
    -> GET  /covers.json   (1.37s of network)
    <- 200  /covers.json
  sequential       3.68s   ['/tracks.json ok', '/artists.json ok', '/covers.json ok']

gather
    -> GET  /tracks.json   (1.12s of network)
    -> GET  /artists.json   (1.16s of network)
    -> GET  /covers.json   (1.37s of network)
    <- 200  /tracks.json
    <- 200  /artists.json
    <- 200  /covers.json
  gather           1.39s   ['/tracks.json ok', '/artists.json ok', '/covers.json ok']

speed-up       : 2.65x
Save it and run it, and give it the seven seconds it needs — the waiting is the lesson. The random.seed(27) line means your three latencies are the same three as mine: 1.12, 1.16 and 1.37 seconds. Only the measured totals will differ, in the last decimal. Now read the two blocks of output as shapes, not numbers. In the sequential run the arrows alternate: -> then <-, -> then <-. Each await finished before the next one started, so the total is the sum of the waits — 3.68 s for 3.65 s of network. In the gather run all three arrows go out first, and only then do the replies come back. The total is 1.39 s: the slowest single leg plus a few milliseconds of bookkeeping. Nothing got faster. The network still took 3.65 seconds of waiting in total — we simply did all of it at the same time, on one thread. That is what the 2.65× is: not speed, overlap. Look at the returned list too. Both runs print the URLs in the same order, because gather hands back results in submission order no matter who finished first. Three things to try. Change one latency to 5 seconds and watch the gather total follow it exactly, because the slowest leg is the answer. Swap asyncio.sleep for time.sleep in download and watch the gather run climb back to 3.7 s — that is the sin of section 6, arriving early. Then add a fourth and a fifth URL: the sequential total grows every time, and the gather total does not move until one of them becomes the slowest.

Now concurrency becomes precise, not hand-wavy. With a thousand Tasks alive, at any instant the ready-queue holds only the few whose awaited thing just became ready. The loop runs each of those forward to its next await, then moves on to the next. Progress interleaves on one thread, in slices, each slice ending at a chosen yield point. Say the words carefully, because the distinction is the soul of the chapter. This is concurrency: many operations in flight, advanced a slice at a time. It is emphatically not parallelism. One core, one thread, exactly one coroutine's bytecode executing at any moment. Ten thousand things in progress, one thing running.

SYNTAX · asyncio.create_task — start it now, collect it laterthe same two half-second jobs: 1.02 s when you await the coroutine, 0.52 s when you schedule it as a Task
await beep() -- runs beep to the END before your next line -- your work and its work never overlap task = asyncio.create_task(beep()) -- SCHEDULED, not started. you keep the thread. ...your own code... -- runs first. the task has not begun. await asyncio.sleep(0.5) -- YOUR first await. now the task gets a turn. value = await task -- collect it. already done -> returns instantly. task.cancel() -- throws CancelledError into the parked frame task.done() -- ask without waiting. True or False. task.result() -- the value, or InvalidStateError if not done tasks = [asyncio.create_task(c) for c in coros] -- fan out, keep the list, then await
await coro()One thing at a time. Your frame parks until the awaited coroutine finishes, so nothing of yours overlaps it.
create_task(coro)Hands the coroutine to the loop as a Task and returns at once. Not one line of the body has run yet.
when it startsAt your next await. Until you yield the thread, the loop has no turn in which to run it.
await taskCollects the value. If the Task already finished, this hands it over without waiting a single loop turn.
.cancel()Throws CancelledError in at the await where the frame is parked, so its finally blocks still run.
keep the referenceA Task nothing points at can be collected mid-flight and simply stop. Keep the variable, or keep the list.
gather vs thisgather is the convenience form and uses Tasks underneath. create_task is the same power, exposed so you can interleave your own work.
you type
import asyncio, time


async def beep():
    print("    beep: tick")
    await asyncio.sleep(0.5)
    print("    beep: tock")
    return "beeped"


async def plain_await():
    print("  me:   asking for a beep")
    await beep()                            # I stop here until beep is finished
    print("  me:   my own 0.5s of work")
    await asyncio.sleep(0.5)


async def with_task():
    print("  me:   scheduling a beep")
    t = asyncio.create_task(beep())         # SCHEDULED, not started -- I keep the thread
    print("  me:   my own 0.5s of work")
    await asyncio.sleep(0.5)                # beep starts HERE and runs during my wait
    print("  me:   collecting ->", await t)


async def cancelled():
    t = asyncio.create_task(beep())
    await asyncio.sleep(0.1)                # let it start, then change your mind
    t.cancel()                              # CancelledError thrown in at the await
    try:
        await t
    except asyncio.CancelledError:
        print("  me:   cancelled it -- 'tock' never printed")


async def main():
    for label, run in (("await beep()", plain_await),
                       ("create_task", with_task),
                       ("task.cancel()", cancelled)):
        print(label)
        t0 = time.perf_counter()
        await run()
        print("  total %.2fs\n" % (time.perf_counter() - t0))


asyncio.run(main())
you see
$ python task_final.py
await beep()
  me:   asking for a beep
    beep: tick
    beep: tock
  me:   my own 0.5s of work
  total 1.02s

create_task
  me:   scheduling a beep
  me:   my own 0.5s of work
    beep: tick
    beep: tock
  me:   collecting -> beeped
  total 0.52s

task.cancel()
    beep: tick
  me:   cancelled it -- 'tock' never printed
  total 0.11s
where beginners trip
  • Read the second block's first two lines. my own 0.5s of work printed before beep: tick — scheduling is not starting.
  • 1.02 s versus 0.52 s, from moving one call into create_task. Nothing ran faster; two waits simply overlapped.
  • If your coroutine never awaits after create_task, the Task never runs. Scheduling without yielding is scheduling into a void.
  • task.result() on an unfinished Task raises InvalidStateError. Use await task when you actually want the value.
  • cancel() is a request, not a kill. It schedules the exception, and the Task stops only once it reaches that await.
  • Discard the Task object and you may lose the work. asyncio keeps only a weak reference, which is why the list matters.
THE ONE IDEA TO CARRY FORWARD
The event loop is a while loop over a deque of callbacks. A Task glues a coroutine to that loop: it steps the coroutine to its next await, parks it as a done-callback on the yielded Future, and gets re-queued only when the Future resolves. One thread, one queue, a cycle per coroutine.
Wait — if a parked Task lives only as a callback on its Future, what keeps it alive? The Future does — and the loop keeps the Task in a set of "pending" references. This is why asyncio warns you to hold a reference to the result of create_task: in some paths a Task with no strong reference can be garbage-collected mid-flight and simply stop, its coroutine's finally blocks firing early. The parked frame is only as durable as the object graph pointing at it — the same rule from section 2, one level up.

One hole remains. If every Task is parked on a Future waiting for a socket, the ready-queue empties and the loop has nothing to run. Who resolves an I/O Future? How does one thread learn that three of ten thousand sockets just went readable — without blocking on any of them? →

05One syscall watches all the sockets

The loop from the last section has a hole you can feel. Suppose every Task is parked on a Future, each waiting for a socket to deliver bytes, and the ready-queue is empty. The while self._ready loop would find nothing to run and simply exit. Or, if you wrote it to keep going, it would spin the CPU at 100% checking an empty deque forever. Neither is a server. Something has to resolve those I/O Futures. And it has to do so for one thread watching ten thousand sockets, without blocking on any single one. This is the exact syscall that pays off section 1's promise: one thread watches all the descriptors.

Recall the file-descriptor model. An open socket is just an integer index into a kernel table. A blocking read(fd) parks the calling thread until fd has data. That is one thread on one fd, which is precisely the model that collapsed. The kernel offers a far better primitive: readiness multiplexing. Instead of "block this thread on this one fd," you hand the kernel a whole set of descriptors and ask a different question — "which of these are ready right now?" Three generations of this exist. There is select(), portable but O(n) in the number of fds and capped at a small limit. There is poll(), with no cap but still O(n). And there is Linux's epoll, O(number ready), scaling to hundreds of thousands of connections. Python wraps all of them behind the selectors module, whose DefaultSelector picks the best one available — epoll on Linux, kqueue on BSD and macOS, select on Windows.

Here is how that completes the loop turn. When a coroutine awaits a socket read, asyncio does not block. It calls loop.add_reader(fd, callback), which registers the fd with the selector (selector.register(fd, EVENT_READ, ...)) and leaves the Task parked on its Future. So the loop's turn is now precisely two steps: run the ready-queue until it is empty, then call selector.select(timeout). That is a single syscall that puts the one thread to sleep on all registered descriptors at once. When any of them becomes readable, the kernel wakes the thread and hands back the list of ready fds. For each one, the loop fires the registered callback. The callback resolves that fd's Future via set_result, which — you know the rest — call_soons the parked Task back onto the ready-queue. You can watch the raw primitive without any of asyncio in the way:

one_thread_many_socks.pypython
import selectors, socket

sel = selectors.DefaultSelector()   # epoll on Linux, kqueue on macOS

def watch(sock):
    sock.setblocking(False)
    sel.register(sock, selectors.EVENT_READ, data=sock)

while True:
    events = sel.select(timeout=2.0)  # ONE syscall sleeps on every fd at once
    if not events:
        print("woke on timeout, nothing ready")  # ~0% CPU while it slept
        continue
    for key, mask in events:      # only the READY ones come back
        data = key.data.recv(4096)
        print(f"fd {key.fd} readable: {len(data)} bytes")

That timeout argument is not a guess. asyncio keeps every scheduled timer and sleep in a heapq of TimerHandles, ordered by wake time. Before each select, it computes the delay until the nearest timer and passes that as the timeout. So await asyncio.sleep(2) is implemented as "put a timer 2 seconds out on the heap, and make sure select wakes the thread by then." I/O readiness and timed sleeps resolve through the same one call. select returns either because a socket went ready or because the next timer came due, whichever happens first.

TYPE THIS · save it, then run itrace_the_tasks.py — five racers collected two ways: as_completed in finishing order, gather in submission order
# race_the_tasks.py -- same five racers, two ways to collect them
import asyncio
import random
import time

random.seed(7)                                   # the same race every time you run it
RACERS = {f"racer-{i}": round(random.uniform(0.2, 1.2), 2) for i in range(1, 6)}


async def racer(name, seconds):
    await asyncio.sleep(seconds)                 # its "leg" of the race
    return name, seconds


async def by_completion():
    """as_completed hands you each result the MOMENT it lands."""
    start = time.perf_counter()
    tasks = [asyncio.create_task(racer(n, s)) for n, s in RACERS.items()]
    print("    all five in flight:", sum(not t.done() for t in tasks), "pending")
    for place, finished in enumerate(asyncio.as_completed(tasks), 1):
        name, seconds = await finished           # the next one to LAND, not the next in the list
        print(f"    {place}. {name:<8} ran {seconds:.2f}s   (arrived t+{time.perf_counter() - start:.2f}s)")


async def by_submission():
    """gather hands you everything at the end, in the order you submitted."""
    start = time.perf_counter()
    tasks = [asyncio.create_task(racer(n, s)) for n, s in RACERS.items()]
    results = await asyncio.gather(*tasks)       # one wait, then the whole list
    for slot, (name, seconds) in enumerate(results, 1):
        print(f"    slot {slot}: {name:<8} ran {seconds:.2f}s   (arrived t+{time.perf_counter() - start:.2f}s)")


async def main():
    print("the field       :", RACERS)
    print("finish order    :", [n for n, _ in sorted(RACERS.items(), key=lambda kv: kv[1])])

    print("\nas_completed -- completion order, each result arrives early:")
    await by_completion()

    print("\ngather -- submission order, every result arrives at the end:")
    await by_submission()


asyncio.run(main())
$ python race_the_tasks.py
the field       : {'racer-1': 0.52, 'racer-2': 0.35, 'racer-3': 0.85, 'racer-4': 0.27, 'racer-5': 0.74}
finish order    : ['racer-4', 'racer-2', 'racer-1', 'racer-5', 'racer-3']

as_completed -- completion order, each result arrives early:
    all five in flight: 5 pending
    1. racer-4  ran 0.27s   (arrived t+0.27s)
    2. racer-2  ran 0.35s   (arrived t+0.37s)
    3. racer-1  ran 0.52s   (arrived t+0.54s)
    4. racer-5  ran 0.74s   (arrived t+0.77s)
    5. racer-3  ran 0.85s   (arrived t+0.88s)

gather -- submission order, every result arrives at the end:
    slot 1: racer-1  ran 0.52s   (arrived t+0.87s)
    slot 2: racer-2  ran 0.35s   (arrived t+0.87s)
    slot 3: racer-3  ran 0.85s   (arrived t+0.87s)
    slot 4: racer-4  ran 0.27s   (arrived t+0.87s)
    slot 5: racer-5  ran 0.74s   (arrived t+0.87s)
Type it, save it, run it. random.seed(7) fixes the field, so your five durations are my five durations and the finish order is identical — only the arrival stamps wobble in the last decimal. Now put the two output blocks side by side and read one column: the arrival stamps. Under as_completed they climb, 0.27 to 0.37 to 0.54 to 0.77 to 0.88, because each result reaches you the instant its Task lands. Under gather every stamp reads 0.87. Same racers, same total time, and nothing came back until the slowest one crossed the line. That is the real difference, and it is not speed. Both collections finished in about 0.87 seconds. What changed is when you could act on the early results. Streaming progress to a user, writing rows as they arrive, failing fast on the first bad response — all of those want as_completed. Wanting the results lined up with the inputs you sent is what gather is for, which is exactly why its list comes back in submission order: racer-1 sits in slot 1 however slow it was. Look at the line above the results too. All five Tasks report pending before the loop's first await, because create_task put every one of them in flight the moment it was called. The for loop is not starting them. It is collecting them. Two things to try. Print time.perf_counter() right after the list comprehension and watch it read near zero — creating five Tasks costs almost nothing. Then break out of the as_completed loop after the second finisher and run it again: the remaining Tasks are still alive, still running, and Python will complain about them on the way out.
One syscall waits on all fds; wakes only for the ready ones loop thread selector.select() timeout = next timer kernel: epoll one sleep over the whole set 10,000 fds — most greyed “not ready” 3 readable only ready fds return fire callback → set_result → Task onto ready-queue one syscall sleeps on all of them; cost scales with the ready few, not the 10,000.
Fig — A single select() sleeps on every socket through epoll and returns only the readable ones — each becomes a callback that sets a Future and re-queues its Task.

Stand back and see the payoff whole. One thread. One epoll_wait. Ten thousand sockets. When nothing is ready, the thread sleeps inside the kernel at zero CPU. It is not spinning. It is genuinely blocked, costing you nothing. When descriptors become readable, the thread wakes and resumes exactly and only the coroutines whose fd just went ready. The cost of a turn scales with the number of ready events, not the number of connections. That is the entire reason epoll exists, and the entire reason ten thousand idle clients are nearly free. The N kernel stacks of section 1 have collapsed into a single selector call.

NOW WRITE IT YOURSELForder the prints — eleven lines, one FIFO queue, and two edits that change everything
First, type the program — exactly this, no shortcuts. Save it as order_prints.py. One coroutine, worker(tag), does six things in order: prints f" {tag}1", awaits asyncio.sleep(0), prints f" {tag}2", awaits asyncio.sleep(0) again, prints f" {tag}3". Then main() does, in order: print "main-A"; x = asyncio.create_task(worker("X")); print "main-B"; y = asyncio.create_task(worker("Y")); print "main-C"; await asyncio.sleep(0); print "main-D"; await x; await y; print "main-E". Finish with asyncio.run(main()). Eleven prints, each firing exactly once. Second, write the eleven lines out on paper before you run it. Guessing and checking teaches nothing here, so commit to an answer first. You need only three facts from this chapter: create_task schedules but does not start, the ready-queue is FIFO, and await asyncio.sleep(0) is the idiom for give up the thread for exactly one turn. Third, answer four questions in writing, still without running it. Where does the first line of worker code actually run, and why is it not at the create_task line? Why does main-D land after X1 and Y1 but before X2? Between main-D and main-E there are four worker lines and no main line at all — where did main go, and what object is holding it? And why does await y cost no time whatsoever? Now run it. If your paper matched the terminal, you are reading the loop correctly. If it did not, find the exact line where the two diverge before you open the solution; that one line is the fact you were missing. Finally, two edits, each predicted first. Edit 1: delete main's await asyncio.sleep(0) and leave everything else alone — which line moves, and which way? Edit 2: replace both create_task calls with plain await worker("X") and await worker("Y"). Exactly one of these two edits destroys all concurrency in the program. Name it, and say why in one sentence, before you run either.
show the solution
# ---------- order_prints.py ----------
import asyncio


async def worker(tag):
    print(f"  {tag}1")
    await asyncio.sleep(0)          # hand the thread back for exactly one turn
    print(f"  {tag}2")
    await asyncio.sleep(0)
    print(f"  {tag}3")


async def main():
    print("main-A")
    x = asyncio.create_task(worker("X"))
    print("main-B")
    y = asyncio.create_task(worker("Y"))
    print("main-C")
    await asyncio.sleep(0)          # main's first yield
    print("main-D")
    await x
    await y
    print("main-E")


asyncio.run(main())


$ python order_prints.py
main-A
main-B
main-C
  X1
  Y1
main-D
  X2
  Y2
  X3
  Y3
main-E


# ---------------- the turn-by-turn trace ----------------
#
# asyncio.run schedules main and starts turning.       ready = [main]
#
# turn 1  pop main
#         print main-A
#         create_task(worker("X"))  -> ready = [X]        X has run NOTHING
#         print main-B
#         create_task(worker("Y"))  -> ready = [X, Y]     Y has run NOTHING
#         print main-C
#         await asyncio.sleep(0)    -> main to the BACK: ready = [X, Y, main]
#
# turn 2  pop X     print X1, sleep(0)  -> ready = [Y, main, X]
# turn 3  pop Y     print Y1, sleep(0)  -> ready = [main, X, Y]
# turn 4  pop main  print main-D
#                   await x -> X is not done, so main parks as a done-callback ON
#                              task X and LEAVES the queue:  ready = [X, Y]
# turn 5  pop X     print X2, sleep(0)  -> ready = [Y, X]
# turn 6  pop Y     print Y2, sleep(0)  -> ready = [X, Y]
# turn 7  pop X     print X3, worker returns. Task X is done, so its done-callback
#                   fires: call_soon(main.step)         -> ready = [Y, main]
# turn 8  pop Y     print Y3, worker returns. Task Y is done.  -> ready = [main]
# turn 9  pop main  resumes at the line after `await x`
#                   await y -> Y is ALREADY done. A finished Task hands back its
#                              result without yielding, so this costs zero turns.
#                   print main-E
#
#
# ---------------- the four answers ----------------
#
# 1. The first worker line runs in turn 2, after main's `await asyncio.sleep(0)`.
#    create_task only appends a handle to the ready-queue. The loop cannot pop that
#    handle while main still holds the one thread -- there is no preemption, so main
#    runs until IT yields. That is why main-A, main-B and main-C all print first.
#
# 2. FIFO. When main finally yields, the queue already reads [X, Y, main]: X and Y
#    were queued at create_task time and main only queued itself just now, so it is
#    third in line. X1 and Y1 print, then main-D. Then main awaits x and leaves the
#    rotation entirely, so X2 comes next with no main line in between.
#
# 3. main is parked as a done-callback on Task X -- exactly the mechanism from
#    section 4. It is not on the ready-queue, so the loop cannot run it, and it is
#    not lost either, because Task X holds the reference. When X returns in turn 7,
#    set_result walks its callbacks and call_soon's main back onto the queue.
#
# 4. Because Y finished in turn 8, before main was resumed in turn 9. Awaiting an
#    already-finished Task returns its result immediately with no suspension at all.
#    An await costs time only when the thing you await is not ready yet.
#
#
# ---------------- edit 1: delete main's await asyncio.sleep(0) ----------------
$ python order_prints_edit1.py
main-A
main-B
main-C
main-D
  X1
  Y1
  X2
  Y2
  X3
  Y3
main-E

# main-D moves UP, above X1. With no yield of its own, main runs straight through
# A, B, C and D, and hands the thread over only at `await x`. The two workers still
# interleave perfectly with each other, because they have their own yield points.
#
#
# ---------------- edit 2: create_task -> plain await ----------------
$ python order_prints_edit2.py
main-A
  X1
  X2
  X3
main-B
  Y1
  Y2
  Y3
main-C

# THIS is the edit that destroys concurrency. `await worker("X")` splices X's frames
# onto main's own suspend chain, so X runs to completion before main's next line.
# X and Y never overlap: X finishes entirely, then Y begins. The sleep(0) calls still
# yield to the loop, but nothing else is ready, so the loop hands the thread straight
# back. One coroutine at a time is not concurrency; it is a slower way to call a
# function.
#
# The one-sentence answer: edit 2 -- because await runs the coroutine inline on your
# own suspend chain, while create_task hands it to the loop as a separate Task that
# can interleave with you.
Interactive · drag the dialwhy epoll exists
Every wake, the loop must find which sockets are ready. Same answer, wildly different cost. SOCKETS WATCHED 10,000 READY THIS WAKE 5 select() / poll() — scans every fd 10,000 ≈ 20 µs of CPU, every wake epoll — returns only the ready 5 ≈ 10 ns, near-constant WORK PER WAKE — LOG SCALED select epoll 2,000× more work per wake — to notice the same 5 ready sockets. Cost scales with the ready few, not the ten thousand — the whole reason epoll exists.
10k
Drag from 100 sockets to one million. select() and poll() hand the kernel the whole set and ask “which are ready?” — so the kernel walks every descriptor, and the cost climbs with the total even though only five had data. epoll keeps a ready-list and returns just those five, so its cost barely moves. Modelled at an order-of-magnitude ~2 ns per descriptor examined. It is the postman knocking on all million doors to deliver five letters — versus the post office handing you the five with mail.
Fig — The same five ready sockets, found two ways: select/poll rescan all N every wake (O(N)); epoll returns only the ready few (O(ready)). Slide N up and watch the red bar chase the total while the green bar stands still.
THE ONE IDEA TO CARRY FORWARD
The bottom of every loop turn is selector.select(timeout) — one syscall that sleeps the single thread on all registered descriptors and returns only the ready ones. Readiness-instead-of-blocking is what turns N thread-stacks into one epoll call. The timeout is the nearest scheduled timer, so sleeps and sockets wake through the same door.
Wait — asyncio registers fds in level-triggered mode, and that choice is deliberate. Level-triggered means "fire as long as the fd is readable," so if a coroutine reads only part of the waiting data and returns to the loop, the very next select reports the fd ready again and the rest gets drained next turn. Edge-triggered ("fire only on the transition to readable") is faster but demands you loop until EAGAIN or you lose data forever. Level-triggered is the forgiving default that makes partial reads safe.

The model is complete and it is beautiful — which is exactly the trap. The single most common async bug is a coroutine that looks fine and quietly freezes the entire server. Time to meet the sharp edge of the cooperative deal. →

06The sharp edge: one blocking call freezes everything

You trust the model now, and that trust is the trap. The most common async bug in existence is a coroutine that looks completely fine — it calls time.sleep, or requests.get, or runs a heavy computation — yet mysteriously wedges the whole server. Heartbeats stop, other connections hang, throughput drops to nothing, and there is no exception to point at. This is not a bug in asyncio. It is the unavoidable flip side of the cooperative deal from section 3. And once you can see why, you can spot it in a diff and fix it in one line. This section is the difference between async code that scales and async code that hangs.

Reassemble the whole machine in one breath. There is one thread. The loop advances a coroutine only by calling send. And send does not return until the coroutine reaches its next await. Crucially — and this is the entire point — there is no preemption. Unlike the OS scheduler, which yanks control away from a thread every few milliseconds whether it cooperates or not (the threading chapter's timer interrupt), nothing will ever interrupt a running coroutine mid-function. Therefore: the loop is blocked inside send for exactly as long as the running coroutine takes to reach its next await. Every failure mode falls straight out of that one sentence.

time.sleep(5) is a blocking syscall that parks the one and only thread in the kernel for five seconds. Not "this coroutine" — the thread. During those five seconds the loop cannot pop the ready-queue, cannot call select, cannot resolve a single Future. Ten thousand sockets could go readable and the thread wouldn't notice, because the thread that would notice is asleep. A CPU-bound loop — hashing a file, summing a huge range, parsing a big document — is the same disease without a syscall. It never executes an await, so send simply does not return for the whole computation. The ready-queue and selector sit untouched the entire time. And requests.get is the subtle killer. It does a blocking socket read on the loop thread instead of yielding a Future, so it stalls everything for the full network round-trip. That is the precise thing async was built to avoid, smuggled back in by an innocent-looking import. See it and un-see it side by side:

block_vs_await.pypython
import asyncio, time

async def heartbeat():
    while True:
        print("beat")
        await asyncio.sleep(0.5)   # yields a timer Future — loop keeps turning

async def bad():
    time.sleep(3)              # parks the ONE thread — every beat freezes 3s

async def good():
    await asyncio.sleep(3)      # parks THIS coroutine — beats keep going

async def cpu_bound():
    total = await asyncio.to_thread(sum, range(10**8))  # push the grind off the loop
    return total

The rule crystallizes to four words: async code must await, never block. The fixes are exact, not vibes. First, replace blocking library calls with their awaiting equivalents. Use await asyncio.sleep instead of time.sleep, and an async HTTP client like aiohttp or httpx instead of requests. Now the wait becomes a Future the loop can park on, and the thread stays free to run everyone else. Second, when the work is genuinely blocking or CPU-bound and cannot be made awaitable, push it off the loop thread. Use await loop.run_in_executor(...) or its friendly wrapper asyncio.to_thread(...). Those hand the work to a thread- or process-pool and give you back a Future. The pool thread blocks or grinds, and the loop thread keeps turning. This is also the one place threads and async cooperate rather than compete. And it is where the GIL still matters: use a process pool, not a thread pool, for CPU-bound work, or the GIL will serialize it right back.

SYNTAX · the blocking sin — and the two ways out of itthe identical three one-second waits, gathered three ways: 3.00 s blocking, 1.01 s awaiting, 1.01 s off-thread
async def sinner(): time.sleep(1) -- BLOCKS the one loop thread. everyone waits. requests.get(url) -- the same sin, wearing a library's clothes sha256(data).hexdigest() -- the same sin with no syscall: it just never awaits async def saint(): await asyncio.sleep(1) -- parks THIS coroutine. the thread stays free. await session.get(url) -- an async client yields a Future instead async def penitent(): -- when the call CANNOT be made awaitable await asyncio.to_thread(blocking_fn, arg) -- I/O-bound: a worker thread await loop.run_in_executor(pool, cpu_fn, arg) -- CPU-bound: a PROCESS pool asyncio.run(main(), debug=True) -- the canary: logs any callback over 100 ms
the sinAny call that holds the thread without reaching an await. A syscall, a library, or a plain for loop — the loop cannot tell them apart.
time.sleepParks the thread, not the coroutine. Three of them in one gather run end to end: 3.00 s, no overlap at all.
await asyncio.sleepYields a timer Future. The three waits overlap and the whole gather costs one second, because the thread was never held.
asyncio.to_threadThe escape hatch for blocking calls you cannot rewrite. It runs them on a worker thread and hands you back a Future.
run_in_executorThe same idea with a pool of your choosing. For CPU-bound work pick a ProcessPoolExecutor; the GIL is still there (ch 26).
debug=TrueTimes every callback and logs any that runs past 100 ms, naming the coroutine with its file and line.
the whole lawAwait, never block. Every line in this box is one of those two words, spelled out.
you type
import asyncio, time


async def sinner(name):
    time.sleep(1)                    # BLOCKS the one loop thread for a whole second
    print(f"  {name} done")


async def saint(name):
    await asyncio.sleep(1)           # parks THIS coroutine; the thread stays free
    print(f"  {name} done")


async def penitent(name):
    await asyncio.to_thread(time.sleep, 1)   # blocking call, pushed off the loop
    print(f"  {name} done")


async def main():
    for label, fn in (("time.sleep", sinner),
                      ("asyncio.sleep", saint),
                      ("to_thread(time.sleep)", penitent)):
        print(label)
        t0 = time.perf_counter()
        await asyncio.gather(fn("a"), fn("b"), fn("c"))
        print("  three 1s waits took %.2fs\n" % (time.perf_counter() - t0))


asyncio.run(main())
you see
$ python block_box.py
time.sleep
  a done
  b done
  c done
  three 1s waits took 3.00s

asyncio.sleep
  a done
  b done
  c done
  three 1s waits took 1.01s

to_thread(time.sleep)
  a done
  b done
  c done
  three 1s waits took 1.01s
where beginners trip
  • async def around a blocking call changes nothing. The keyword is a promise about yielding; it enforces no part of it.
  • 3.00 s for a gather of three one-second waits is the tell. If concurrency bought you nothing, something in there is blocking.
  • to_thread does not make blocking code fast. It moves the block off the loop so everyone else keeps running.
  • For CPU-bound work a thread pool hands the GIL problem straight back. Use a process pool, or the total will not budge.
  • requests, time.sleep, open(...).read() and most of the stdlib block. Their async replacements are separate packages.
  • The failure is silent. No exception, no traceback, only latency — which is why debug=True is the tool that finds it.
One blocking call freezes the whole loop the one loop thread A B C A • = await tick, tight interleave time.sleep(5) / big CPU loop — no await select() not called for 5s ready-queue, stalled: B C ready but cannot run socket readable— ignored Fix: hand the blocking work to an executor thread loop thread A B C A … tight slices resume executor thread run_in_executor(…) — the 5s blocks HERE, off the loop await handed off
Fig — A single non-awaiting call blocks the loop for 5 s while ready Tasks pile up; moving it into run_in_executor keeps the loop’s tight interleave alive.

asyncio even ships a canary for exactly this. Run your program with asyncio.run(main(), debug=True) and the loop times every callback; any that runs longer than 100 ms gets logged — Executing <Handle ...> took 0.512 seconds. That turns the invisible freeze into a visible warning pointing at the offending coroutine. Turn it on the first time a server feels mysteriously sluggish and the culprit usually names itself.

NOW WRITE IT YOURSELFtwo programs: convert a blocking script to async, then find the blocker hiding inside an async one
Part A — convert a sync script, and measure the win. Write dashboard_sync.py first, as ordinary blocking code. Three plain functions, each taking user: fetch_profile does time.sleep(0.7) and returns {"user": user, "plan": "pro"}; fetch_orders does time.sleep(0.5) and returns ["order-19", "order-27"]; fetch_recommendations does time.sleep(0.9) and returns ["Titanium", "Levels"]. A fourth function, dashboard(user), calls all three and returns one dict with keys profile, orders and picks. Time it under a __main__ guard and write the number down. Now convert it. The three lookups do not depend on each other, so they have no business happening one at a time. Only two kinds of edit are allowed: change what each function is, and change how dashboard calls them. Before you run the async version, predict its total to two decimal places — the arithmetic is the whole lesson, and there is only one right answer. Part B — find the blocker. Write monitor_bad.py: a module-level START = time.perf_counter() and a stamp(msg) helper printing f" t+{time.perf_counter() - START:4.1f}s {msg}". Three coroutines. heartbeat() loops eight times, stamping "beat" then awaiting asyncio.sleep(0.25). poll_status() loops four times, awaiting asyncio.sleep(0.5) then stamping "status ok". checksum(path) stamps "checksum: start", calls time.sleep(1.0), then stamps the first eight characters of hashlib.sha256(path.encode()).hexdigest(). main() gathers all three. Run it and read the timestamps, not the code: there is a hole in the beat where nothing at all happened. Measure the hole. Then prove it without reading a line, by running the same program under asyncio.run(main(), debug=True) — the loop will name the offender for you. Now fix it, and answer three things. The fix is two edits and no new dependency: pull the blocking work into an ordinary def, and reach it from the coroutine through asyncio.to_thread. Run it and confirm the beat is even again. Then write down: why did poll_status stall too, when it never touched checksum? Did the fix make the checksum any faster, and at what timestamp does it now land? And if the blocking second were a CPU-bound hash of a two-gigabyte file instead of a sleep, would to_thread still be the right tool — and what would you reach for instead?
show the solution
# ================= PART A =================
# ---------- dashboard_sync.py ----------
import time


def fetch_profile(user):
    time.sleep(0.7)                       # pretend: one HTTP round-trip
    return {"user": user, "plan": "pro"}


def fetch_orders(user):
    time.sleep(0.5)
    return ["order-19", "order-27"]


def fetch_recommendations(user):
    time.sleep(0.9)
    return ["Titanium", "Levels"]


def dashboard(user):
    return {
        "profile": fetch_profile(user),
        "orders": fetch_orders(user),
        "picks": fetch_recommendations(user),
    }


if __name__ == "__main__":
    t0 = time.perf_counter()
    print(dashboard("ada"))
    print("took %.2fs" % (time.perf_counter() - t0))


$ python dashboard_sync.py
{'profile': {'user': 'ada', 'plan': 'pro'}, 'orders': ['order-19', 'order-27'], 'picks': ['Titanium', 'Levels']}
took 2.10s


# ---------- dashboard_async.py ----------
import asyncio
import time


async def fetch_profile(user):
    await asyncio.sleep(0.7)              # the ONE line that changes per function
    return {"user": user, "plan": "pro"}


async def fetch_orders(user):
    await asyncio.sleep(0.5)
    return ["order-19", "order-27"]


async def fetch_recommendations(user):
    await asyncio.sleep(0.9)
    return ["Titanium", "Levels"]


async def dashboard(user):
    profile, orders, picks = await asyncio.gather(
        fetch_profile(user),
        fetch_orders(user),
        fetch_recommendations(user),
    )
    return {"profile": profile, "orders": orders, "picks": picks}


async def main():
    t0 = time.perf_counter()
    print(await dashboard("ada"))
    print("took %.2fs" % (time.perf_counter() - t0))


if __name__ == "__main__":
    asyncio.run(main())


$ python dashboard_async.py
{'profile': {'user': 'ada', 'plan': 'pro'}, 'orders': ['order-19', 'order-27'], 'picks': ['Titanium', 'Levels']}
took 0.90s


# 2.10s -> 0.90s. The predicted number was max(0.7, 0.5, 0.9) = 0.90, not the sum
# 2.10, and that is the only arithmetic async ever does for you. Four edits, total:
#   1. def -> async def, on all three lookups
#   2. time.sleep -> await asyncio.sleep, one line each
#   3. dashboard -> async def, and its three sequential calls -> one gather
#   4. asyncio.run(main()) at the bottom, replacing the direct call
#
# The trap to avoid: writing `await fetch_profile(user)` three times on three lines.
# That is a correct async program and it still takes 2.10s, because three awaits in
# a row are three waits in a row. The gather is what makes them overlap.


# ================= PART B =================
# ---------- monitor_bad.py ----------
import asyncio
import hashlib
import time

START = time.perf_counter()


def stamp(msg):
    print(f"  t+{time.perf_counter() - START:4.1f}s  {msg}")


async def heartbeat():
    for _ in range(8):
        stamp("beat")
        await asyncio.sleep(0.25)


async def poll_status():
    for _ in range(4):
        await asyncio.sleep(0.5)
        stamp("status ok")


async def checksum(path):
    stamp("checksum: start")
    time.sleep(1.0)                       # <-- the blocker
    digest = hashlib.sha256(path.encode()).hexdigest()
    stamp(f"checksum: {digest[:8]}")


async def main():
    await asyncio.gather(heartbeat(), poll_status(), checksum("library.json"))


asyncio.run(main())


$ python monitor_bad.py
  t+ 0.0s  beat
  t+ 0.0s  checksum: start
  t+ 1.0s  checksum: 64cc7cdd
  t+ 1.0s  beat
  t+ 1.0s  status ok
  t+ 1.3s  beat
  t+ 1.5s  status ok
  t+ 1.5s  beat
  t+ 1.8s  beat
  t+ 2.0s  status ok
  t+ 2.0s  beat
  t+ 2.3s  beat
  t+ 2.5s  status ok
  t+ 2.5s  beat

# The hole: one beat at t+0.0, the next at t+1.0. The heartbeat asked for 0.25s and
# got 1.0s. poll_status should have stamped at 0.5s and did not stamp until 1.0s.


$ python monitor_debug.py            # same file, asyncio.run(main(), debug=True)
Executing <Task finished name='Task-4' coro=<checksum() done, defined at
  ...\monitor_debug.py:25> result=None created at ...\asyncio\tasks.py:695>
  took 1.000 seconds
  t+ 0.0s  beat
  t+ 0.0s  checksum: start
  ...

# There is the name, the file and the line, without reading a character of the code.
# The threshold is 100 ms; this callback held the loop for 1000 of them.


# ---------- monitor_fixed.py  (only these two definitions change) ----------
def _checksum_blocking(path):             # ordinary def -- it is allowed to block
    time.sleep(1.0)
    return hashlib.sha256(path.encode()).hexdigest()


async def checksum(path):
    stamp("checksum: start")
    digest = await asyncio.to_thread(_checksum_blocking, path)   # off the loop thread
    stamp(f"checksum: {digest[:8]}")


$ python monitor_fixed.py
  t+ 0.0s  beat
  t+ 0.0s  checksum: start
  t+ 0.3s  beat
  t+ 0.5s  status ok
  t+ 0.5s  beat
  t+ 0.8s  beat
  t+ 1.0s  checksum: 64cc7cdd
  t+ 1.0s  status ok
  t+ 1.0s  beat
  t+ 1.3s  beat
  t+ 1.5s  status ok
  t+ 1.5s  beat
  t+ 1.8s  beat
  t+ 2.0s  status ok

# The beat is even now: 0.0, 0.3, 0.5, 0.8, 1.0, 1.3, 1.5, 1.8. Same digest, same
# 1.0s of work, and nobody else paid for it.


# ---------------- the three answers ----------------
#
# 1. poll_status stalled because there is exactly ONE thread and no preemption. The
#    loop was frozen inside checksum's send(), so it could not pop poll_status off
#    the ready-queue and could not even reach selector.select() to notice its timer
#    was due. Being "ready" means nothing while another coroutine holds the thread.
#    Nothing connects the two coroutines except the thread they share -- and that is
#    enough.
#
# 2. No. The checksum still lands at t+1.0s in both runs; the work was never the
#    problem. to_thread does not make blocking code fast, it makes it somebody
#    else's problem -- a worker thread's. What changed is that the other two
#    coroutines kept their schedule while it ran.
#
# 3. No -- to_thread would be the wrong tool for a CPU-bound hash. A worker thread
#    still needs the GIL to execute Python bytecode (ch 26), so the grind would be
#    serialised straight back against the loop thread. hashlib does release the GIL
#    around its C digest calls, which is the honest exception; pure-Python CPU work
#    does not. For that, reach for a process pool:
#        loop = asyncio.get_running_loop()
#        with ProcessPoolExecutor() as pool:
#            digest = await loop.run_in_executor(pool, _cpu_heavy, path)
#    A separate process has its own interpreter and its own GIL -- which is exactly
#    where Chapter 28 picks up.
ONE BLOCKING CALL STALLS EVERY TASK
There is no rescue thread. If a coroutine doesn't reach an await, the loop is frozen inside send and nothing else runs — not a ready socket, not a due timer, not another Task. A single time.sleep, requests.get, or heavy loop on the loop thread freezes the entire server for its full duration.

✗ The myth

"It's async, so slow operations run in the background and won't block anything."

✓ The reality

Only awaited operations yield the thread. A blocking call runs on the single loop thread and holds it hostage until it finishes. "Background" only exists if you send the work to an executor or await a genuinely async API — otherwise there is no background, only the one thread you froze.

End on the symmetry, because it is the honest summary of the whole chapter. Preemption — threads — costs you races. Any thread can be interrupted mid-update, so shared state needs locks. But it rescues you from any single stuck task, because the scheduler will always take control back. Cooperation — async — gives you race-free code between awaits, because nothing interrupts you. But it makes every coroutine personally responsible for yielding, because nothing will take control back if you don't. Same coin, two faces. You traded the OS's rescue for the OS's races, and the price of that trade is a single discipline: await often, block never.

SYNTAX · when async actually wins — and when it is the wrong toolmeasured, not asserted: at eight waiters threads and async tie exactly; at five thousand, one costs 133 MB and the other 7.6 MB
# the only question that matters: is this program WAITING, or COMPUTING? mostly WAITING, thousands at once -> async. one thread, one epoll, tiny frames mostly WAITING, a dozen at once -> threads. ch 26. simpler code, same speed. mostly COMPUTING -> neither. processes, ch 28. the GIL is the wall. one blocking library you must use -> async + asyncio.to_thread around that call # measured on this machine, python 3.12 -- your numbers, same shape 8 waits of 1.0s serial 8.01s threads 1.01s async 1.01s -- a tie 8 CPU burns serial 6.68s threads 6.55s async 6.51s -- nobody wins 5,000 idle waiters threads +133.1 MB coroutines +7.6 MB
the one axisIs your program waiting or computing? Every honest answer about async falls out of that question.
many waitsThousands of mostly-idle connections. This is async's home ground — 5,000 waiters for 7.6 MB instead of 133 MB.
a few waitsEight waits: threads 1.01 s, async 1.01 s. A dead tie. Reach for whichever code you would rather read in a year.
CPU workEight burns: serial 6.68 s, threads 6.55 s, async 6.51 s. Neither model moved the needle, because the GIL is the wall.
why threads lose at scaleNot speed — memory and scheduler pressure. The wall clock stayed the same; it is the machine that could not take ten thousand of them.
the price of asyncIt is contagious. One async def at the bottom makes every caller above it async, and every library you touch has to cooperate.
what comes nextFor real parallel computation you need a second interpreter, which means a second process. That is Chapter 28.
you type
# decide.py -- three workloads, three models, one honest table
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor


def wait_io(_):                       # a WAIT: the thread sits doing nothing
    time.sleep(1.0)


def burn_cpu(_):                      # WORK: pure Python, needs the GIL the whole time
    total = 0
    for i in range(6_000_000):
        total += i
    return total


async def wait_io_async(_):
    await asyncio.sleep(1.0)


async def burn_cpu_async(_):
    return burn_cpu(_)                # nothing to await -- this is the trap


def timed(label, fn):
    t0 = time.perf_counter()
    fn()
    print(f"  {label:<28} {time.perf_counter() - t0:.2f}s")


def main():
    n = 8
    print(f"{n} waits of 1.0s each")
    timed("serial (plain for-loop)", lambda: [wait_io(i) for i in range(n)])
    timed("threads (ThreadPoolExecutor)",
          lambda: list(ThreadPoolExecutor(n).map(wait_io, range(n))))
    timed("async (gather)",
          lambda: asyncio.run(_gather(wait_io_async, n)))

    print(f"\n{n} CPU burns of ~6M additions each")
    timed("serial (plain for-loop)", lambda: [burn_cpu(i) for i in range(n)])
    timed("threads (ThreadPoolExecutor)",
          lambda: list(ThreadPoolExecutor(n).map(burn_cpu, range(n))))
    timed("async (gather)",
          lambda: asyncio.run(_gather(burn_cpu_async, n)))


async def _gather(fn, n):
    return await asyncio.gather(*(fn(i) for i in range(n)))


main()



# ---------- mem.py ----------  (run twice: `python mem.py threads`, then `async`)
import asyncio, os, sys, threading, time
import psutil

P = psutil.Process(os.getpid())
def mb(): return P.memory_info().rss / 1024 / 1024

N = 5000
mode = sys.argv[1]
base = mb()
t0 = time.perf_counter()

if mode == "threads":
    stop = threading.Event()
    ts = [threading.Thread(target=stop.wait, daemon=True) for _ in range(N)]
    for t in ts: t.start()
    time.sleep(1.0)
    print(f"{N} blocked threads    : +{mb() - base:6.1f} MB   built in {time.perf_counter() - t0:.2f}s")
    stop.set()
else:
    async def waiter(): await asyncio.sleep(30)
    async def main():
        tasks = [asyncio.create_task(waiter()) for _ in range(N)]
        await asyncio.sleep(1.0)
        print(f"{N} suspended coros    : +{mb() - base:6.1f} MB   built in {time.perf_counter() - t0:.2f}s")
        for t in tasks: t.cancel()
    asyncio.run(main())
you see
$ python decide.py
8 waits of 1.0s each
  serial (plain for-loop)      8.01s
  threads (ThreadPoolExecutor) 1.01s
  async (gather)               1.01s

8 CPU burns of ~6M additions each
  serial (plain for-loop)      6.68s
  threads (ThreadPoolExecutor) 6.55s
  async (gather)               6.51s

$ python mem.py threads
5000 blocked threads    : + 133.1 MB   built in 1.31s

$ python mem.py async
5000 suspended coros    : +   7.6 MB   built in 1.03s
where beginners trip
  • “Async is faster” is false. It is not faster. It is cheaper to hold thousands of waiters, which is a different claim.
  • Threads matched async exactly at eight waiters. Below a few hundred connections the simpler model usually wins on merit.
  • One await asyncio.sleep(0) sprinkled into a CPU loop does not fix it. You gave up the thread for one turn, not for the work.
  • That 133 MB works out at about 27 KB actually resident per blocked thread. The reserved stack is far bigger; the OS simply has not had to back it yet.
  • Both CPU rows sit near the serial row. If your workload looks like that, no amount of async def will help it.
  • Measure before you choose. These four lines took ten minutes to write and settled the question for this machine, honestly.
WHERE THIS LEAVES YOU
A coroutine is a stack frame kept on the heap; await is a compiled yield that surfaces a Future; the event loop is one thread running a ready-queue whose bottom is one epoll call over every socket. Ten thousand idle waiters cost a few megabytes and zero CPU — as long as no coroutine ever forgets to yield.
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 27, in working code

Chapter 27 unmasks async/await: a coroutine is just a suspendable stack frame, and the event loop is a scheduler that resumes those frames at their await points. These runs start from the frame-as-object, drive coroutines by hand to expose the plain generator yield hiding under await, then hand control to the real asyncio loop to schedule concurrent I/O — and close on the single rule that makes or breaks it all: never block the loop. Every output here is deterministic by construction.

The coroutine object — a resumable frame
Calling an async function runs nothing — it hands you a coroutine object whose frame is frozen. These three show the object itself, prove each call gets its own independent frame, and peek at locals preserved across a suspend.
Driving coroutines by hand
await is not magic. Underneath, a coroutine yields to whoever drives it. We drive by hand with send(), build a full round-robin scheduler in a dozen lines, and show that awaiting another coroutine is transparent — only a leaf yield reaches the loop.
The event loop schedules
Now the real asyncio loop. gather preserves argument order while completion order follows the timers; create_task plus sleep(0) makes the cooperative hand-off visible. Delays are chosen far apart so the interleaving is deterministic every run.
Blocking vs cooperative
The payoff and the trap, side by side. A blocking call inside a coroutine starves every other task; the same code with await overlaps. When you must run blocking work, to_thread moves it off the loop — and we print from returned values so output never depends on thread timing.
end of chapter 27 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked