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.
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.
# 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 MiBNow 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.
✗ 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.
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:
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:
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 StopIterationSit 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.
async def — the whole skeleton, in eight linescalling a coroutine function runs nothing at all; await runs it, and asyncio.run starts the one loopasync defOne keyword sets CO_COROUTINE on the code object. Same body, same def — different call behaviour.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.coroutine '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 defyou 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 functiongreet("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.awaitoutside anasync defis a SyntaxError, refused at compile time. There is notrythat catches it.- A coroutine is not its result. Add one, print one, index one, and you are handling the object, never the value.
asyncio.runis a top-level call. Inside a running loop it raisesRuntimeError: 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
ProactorEventLoopand the system timer ticks about every 15.6 ms, so eachasyncio.sleepovershoots by roughly that much.
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.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:
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 okThe 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.
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__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 def with a yield in it. It can await between items, so each item may come off the wire as it lands.async def. Outside one they are a SyntaxError, exactly like a bare await.with cannot await, so it cannot express that.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 listyou 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- Plain
withon an async manager:TypeError: 'Connection' object does not support the context manager protocol. It looked for__enter__, which is not there. async forover a list fails too. A list is iterable; it is not async-iterable, and the two protocols never overlap.async foris sequential, not concurrent. It awaits one item at a time — reach forgatherwhen you want overlap.- An async generator may not
returna value:SyntaxError: 'return' with value in async generator. A barereturnis fine. - Forget the
asynconwithand the error points at thewithline, not at the awaits inside the block. - None of this starts a loop. Every example here still needs its
asyncio.runat 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.
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.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:
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 resolvesTrace 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.
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*gather takes coroutines as separate arguments, so a list has to be unpacked: gather(*coros).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.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')]- Read the results line twice.
slowwas 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.sleeplands a hair late. - Gathering coroutines that never
awaitbuys nothing. With no yield point they run one after another anyway. - The exception arrived at 0.1 s but
leftandrightstill finished. Orphaned Tasks keep going until something cancels them.
# 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
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.
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 Taskawait 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.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.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- Read the second block's first two lines.
my own 0.5s of workprinted beforebeep: 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 raisesInvalidStateError. Useawait taskwhen you actually want the value.cancel()is a request, not a kill. It schedules the exception, and the Task stops only once it reaches thatawait.- Discard the Task object and you may lose the work.
asynciokeeps only a weak reference, which is why the list matters.
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.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:
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.
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)
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.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.
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.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.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.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.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:
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 totalThe 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.
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.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.01sasync defaround a blocking call changes nothing. The keyword is a promise about yielding; it enforces no part of it.- 3.00 s for a
gatherof three one-second waits is the tell. If concurrency bought you nothing, something in there is blocking. to_threaddoes 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=Trueis the tool that finds it.
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.
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.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.
async def at the bottom makes every caller above it async, and every library you touch has to cooperate.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- “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 defwill help it. - Measure before you choose. These four lines took ten minutes to write and settled the question for this machine, honestly.
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.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.