The Two-Way Door
Generators, send, throw, yield from, and the single protocol all of asyncio is made of. Open a REPL — every snippet here is meant to be run.
Part 4 of the cancellation post lost you, and rightly. It claimed "async doesn't mean yields" without first building the picture of what a yield even is. This post fixes that. By the end, you should be able to take a single coroutine, run it under a driver you wrote yourself in forty lines, and point at the exact instruction where it parks.
The whole of asyncio is one mechanism repeated at three layers: a function you can pause, a value that flows out of the pause, and a driver that pumps the resumes. Generators are the mechanism. Coroutines are generators with a coat of paint. Futures and Tasks are the bookkeeping that makes the mechanism schedulable. There is nothing else.
In Go, a goroutine parks and the runtime does the bookkeeping invisibly — you cannot point at the instruction. Python makes the same bookkeeping a language-level protocol you can see, implement, and step through with pdb. The cost is a small protocol you have to learn. The win is: there is no magic left after this post.
§1A function you can pause
A generator is a function whose execution you can stop in the middle and resume later. The stop point is yield. That is the only new idea on the page.
def counter(): yield 1 yield 2 yield 3 g = counter() # calling it does NOT run the body; it returns a generator object print(g.send(None)) # 1 — runs until first yield, pauses print(g.send(None)) # 2 — resumes, runs until next yield print(g.send(None)) # 3 print(g.send(None)) # raises StopIteration — body fell off the end
Three things to notice. Calling counter() doesn't print anything — generators are lazy; the body runs only when something pumps the generator. Each send runs the body until it hits a yield, at which point execution suspends with the local frame intact — variables, line number, everything. The fourth send raises StopIteration because the body has nothing left to do.
You may know next(g) instead of g.send(None). They are the same — next is just sugar for "resume with None". I'll use send throughout because the asyncio machinery uses send explicitly, and we want to keep one vocabulary.
Paste counter into a REPL. Call g.send(None) four times. Observe the StopIteration.
Then: add print("before yield 2") between the first two yields. Does it print on the first send or the second? Predict, then check.
§2yield is a two-way door
The thing that makes yield more than "pause" is that it carries a value in both directions. yield E emits E outward — that becomes the return value of send. And yield as an expression also evaluates to whatever you .send() back in on the next resume.
def echo(): while True: received = yield "what?" print("you sent:", received) g = echo() print(g.send(None)) # "what?" print(g.send("hi")) # prints "you sent: hi", then returns "what?" print(g.send("bye")) # prints "you sent: bye", then returns "what?"
Trace one full round trip. g.send(None) runs the body up to yield "what?"; the string travels outward and becomes the return value of send. The generator is paused with execution sitting at the yield, the value "what?" already handed off. When you call g.send("hi"), the "hi" travels inward and becomes the value of the yield expression — so received = "hi". The body then runs until the next yield, which emits another "what?".
yield E does two things: it emits E outward, and on resume it evaluates to whatever came back in.
This is also why the first send must be send(None). There is no yield expression paused yet, waiting to receive a value — the body hasn't started running. Sending anything other than None on the first call raises TypeError: can't send non-None value to a just-started generator. Try it.
What value does g.send(None) in echo assign to received on the second call? Predict, then run g = echo(); g.send(None); g.send(None) and read it. (The answer is None — a yield can receive any value, including the absence of one.)
§3Three ways back in: send, throw, close
You have three methods to resume a paused generator. They differ in what happens at the yield point when execution lands back on it.
g.send(value) # resume with `value` as the result of the yield expression g.throw(exc) # resume by RAISING `exc` at the yield point g.close() # resume by raising GeneratorExit; cleanup only — cannot be suppressed
The throw path matters because it is exactly how cancellation works. When asyncio cancels a Task, it ultimately calls coro.throw(CancelledError()) on the running coroutine — the await that was paused suddenly raises CancelledError instead of returning a value, and the exception unwinds through the call stack like any other.
def life(): try: while True: x = yield print("got:", x) except ValueError as e: print("caught:", e) finally: print("cleanup") g = life() g.send(None) # start — pause at yield g.send(1) # prints: got: 1 g.throw(ValueError("oops")) # prints: caught: oops, then cleanup, then StopIteration
throw raises the exception at the yield, not at the call site. The body's try/except catches it. If the body did not catch ValueError, the exception would propagate out of throw into the caller — same as any uncaught exception. The finally always runs.
Replace g.throw(ValueError("oops")) with g.close(). Run it. What prints? Why? (Hint: close raises GeneratorExit, which your except ValueError does not match, but finally still runs.)
Bonus: what happens if you try to yield from inside an except GeneratorExit clause? The interpreter refuses — close is mandatory, not advisory.
§4yield from — transparent delegation
You write a generator that wants to delegate some of its work to another generator. The naïve way is to loop: for x in inner(): yield x. This forwards yields outward, but it does not forward send values or throw exceptions inward — they get absorbed by the for-loop, not the inner generator. yield from fixes that: it splices the inner generator in transparently. Sends, throws, and the inner's return value all flow through.
def inner(): x = yield "inner-1" print("inner got:", x) return 99 # becomes the value of `yield from inner()` def outer(): result = yield from inner() print("outer got:", result) yield "outer-done" g = outer() print(g.send(None)) # "inner-1" — outer is paused inside inner's first yield print(g.send("hello")) # prints "inner got: hello", "outer got: 99", returns "outer-done"
Three subtleties worth pinning down. First, yield from inner() is itself a single expression whose value is whatever inner returns — that's how a delegated generator hands a result back. Second, while outer is suspended inside inner, the topmost send drives inner directly — the outer's frame is just a passthrough. Third, this is the protocol await will turn out to be a thin sugar over.
§5async def is sugar over a generator
An async def function compiles, mechanically, to something very close to a generator. The compiled code object carries a CO_COROUTINE flag instead of CO_GENERATOR, but the underlying machinery is the same. The biggest user-visible difference is the keyword: you write await x instead of yield from x, and await x desugars (essentially) to yield from x.__await__().
class Awaitable: def __await__(self): received = yield "signal" # yielded UP to whoever is pumping us print("awaitable got:", received) return 42 # becomes the value of `await Awaitable()` async def coro(): x = await Awaitable() return x + 1 c = coro() print(c.send(None)) # "signal" try: c.send("hi") # "awaitable got: hi"; raises StopIteration(43) except StopIteration as e: print("done:", e.value) # done: 43
Read it line by line. coro() returns a coroutine object — same lazy story as a generator. c.send(None) runs the body until await Awaitable(), which desugars to yield from Awaitable().__await__(). That descends into the iterator, hits yield "signal", and the string travels all the way up to the topmost send. Now we resume with "hi": it flows down through the yield from to land at the yield inside __await__, where received = "hi". __await__ then returns 42, which becomes the value of the await expression in coro, which assigns x = 42. The body returns 43, which surfaces as StopIteration(43) at the top.
"await yields control to the event loop." Half right. await x yields control only if x.__await__() reaches a yield on something the loop has not yet resolved. If __await__ runs straight through and returns, the await evaluates immediately and no suspension happens. async grants the right to suspend. It does not force one.
§6The Future: a parking spot you can resolve
Everything above is generic. Now the asyncio-specific layer. asyncio has one type whose __await__ matters: asyncio.Future. It is a placeholder for a value that will arrive later, plus the machinery to wake whoever is waiting on it. Real CPython source, the one method that matters:
# cpython/Lib/asyncio/futures.py def __await__(self): if not self.done(): self._asyncio_future_blocking = True yield self # hand SELF up to the Task if not self.done(): raise RuntimeError("await wasn't used with future") return self.result() # the data channel: returns the value or raises
This is the entire pivot of asyncio. When the Future is not done, it yields itself — not a status code, not None, the actual Future object. That object travels up through every yield from in the chain and lands at the topmost driver, which is a Task. The Task pulls the Future out of send, looks at it, and says: "ah, a Future. I'll register a callback to wake me when it resolves, and stop pumping until then." The coroutine is now parked on that Future.
One question this answers cleanly: in the asyncio machinery, what does .send() actually carry? On the way up: the Future (the parking ticket). On the way back down: nothing useful — the Task always resumes with send(None). So where does the result come from? From self.result() inside __await__, after the resume. The Future is the data channel. send is just the scheduling channel. That is a real distinction, and it is the one your draft glossed.
Picture two pipes between the coroutine and the Task. The scheduling pipe carries one signal: "park me on this Future." The data pipe is the Future object itself, into which the result will eventually be poured. Resume is a kick that says "look at your Future again" — and the Future, now done(), hands you the value via self.result().
§7The Task: the driver that pumps the coroutine
A Task is the thing that calls coro.send(None) repeatedly until the coroutine is done. It is created by asyncio.create_task or by the event loop's own bootstrap. The body of Task.__step is the loop you would have written yourself if you'd been asked to write a driver. Trimmed but honest:
# cpython/Lib/asyncio/tasks.py — Task.__step, the heart of it def __step(self, exc=None): coro = self._coro try: if exc is None: result = coro.send(None) # pump it else: result = coro.throw(type(exc), exc) # inject (e.g. CancelledError) except StopIteration as e: self.set_result(e.value) # coroutine finished — record result return else: if getattr(result, "_asyncio_future_blocking", False): # it's a Future — park on it self._fut_waiter = result result.add_done_callback(self.__wakeup) def __wakeup(self, fut): self._fut_waiter = None self.__step() # pump again — the Future is now done
One send, one of two outcomes. Either the coroutine finished (StopIteration) and we record its return value, or it yielded a Future — in which case we save the Future in _fut_waiter and register __wakeup as the Future's done-callback. When the Future is resolved (by a timer firing, a socket becoming readable, another task calling set_result), __wakeup runs, which calls __step, which calls coro.send(None) again. Back inside the coroutine, control returns from the yield self in Future.__await__; the if not self.done(): guard now passes; self.result() returns the value; the await expression evaluates; the coroutine runs onward until the next yield.
_fut_waiter is a real attribute on the Task. anyio reads it directly to ask "is this task currently parked on a Future?" — which is how cancellation decides whether to deliver inline or defer. That comes next post.
§8One clean trace, no elision
Time to put it all together. Below is a complete, runnable asyncio in fifty lines — a toy event loop, a toy Future, a toy Task, a toy sleep, and two coroutines that race. Paste it into a file, run it with python tiny.py, and read along.
# tiny.py — asyncio in 50 lines. Runs on plain CPython. No imports of asyncio. import heapq, time, types class Future: def __init__(self): self._done = False self._result = None self._callbacks = [] def done(self): return self._done def set_result(self, v): self._done = True; self._result = v for cb in self._callbacks: cb(self) def add_done_callback(self, cb): self._callbacks.append(cb) def __await__(self): if not self._done: yield self # the one true suspension return self._result class Loop: def __init__(self): self._ready = [] # tasks to step now self._scheduled = [] # [(when, callback), ...] — heap def call_later(self, delay, cb): heapq.heappush(self._scheduled, (time.monotonic() + delay, cb)) def create_task(self, coro): t = Task(coro, self); self._ready.append(t); return t def run(self): while self._ready or self._scheduled: now = time.monotonic() while self._scheduled and self._scheduled[0][0] <= now: _, cb = heapq.heappop(self._scheduled); cb() if self._ready: t = self._ready.pop(0); t.step() elif self._scheduled: time.sleep(max(0, self._scheduled[0][0] - now)) class Task: def __init__(self, coro, loop): self.coro = coro; self.loop = loop def step(self): try: fut = self.coro.send(None) # pump — get a Future back except StopIteration: return # done fut.add_done_callback(lambda _f: self.loop._ready.append(self)) def sleep(loop, delay): fut = Future() loop.call_later(delay, lambda: fut.set_result(None)) return fut # a Future IS awaitable — see __await__ above async def say(loop, name, delay): await sleep(loop, delay) print(name, "at", round(time.monotonic() - t0, 2)) loop = Loop(); t0 = time.monotonic() loop.create_task(say(loop, "A", 1.0)) loop.create_task(say(loop, "B", 0.5)) loop.run() # prints B at 0.5, then A at 1.0
Run it. Output: B at 0.5, then A at 1.0. Total wall time ≈ 1s, not 1.5s — because they ran concurrently.
Trace the first few steps mentally with the code in front of you. loop.run() pops Task A off _ready and calls step. step runs self.coro.send(None) — this descends into say, hits await sleep(loop, 1.0), which builds a Future, schedules a timer to resolve it at t0+1.0, and returns the Future. await on a Future calls Future.__await__, which yields self upward. Task A's step receives the Future, registers add_done_callback(lambda _: ready.append(self)), and returns. Task A is now parked. Same dance for Task B with delay 0.5.
Both tasks parked, _ready empty, _scheduled has two timers. Loop sleeps until the nearer one fires. At t0+0.5, the callback calls fut.set_result(None), which runs the done-callback, which appends Task B to _ready. Loop wakes, pops B, calls step, calls send(None). Back inside Task B's coroutine, the yield self in Future.__await__ resumes; self._done is True; __await__ returns self._result (None); the await evaluates; control flows to the print line. B at 0.5 prints. say body falls off the end → StopIteration → Task B is done. Same for A at t0+1.0.
Open tiny.py in pdb and break on Task.step. Step into self.coro.send(None) the first time and watch the stack go: step → say → sleep returns a Future → await Future → Future.__await__ → yield self. The yield self is the only line where control leaves the coroutine.
Then: add a second await sleep(loop, 0.5) inside say. Predict the output. Run it. Why does each task now take its full delay before printing?
§9Take with you
- A generator is a function with pause points. Calling it returns a generator object;
sendpumps it until the nextyieldorStopIteration. yield Eis a two-way door.Etravels outward as the return value ofsend; whatever the nextsendcarries travels inward as the value of theyieldexpression.- Three ways back in:
send(v),throw(exc),close().throwraises at the yield point, which is exactly how cancellation lands. yield fromsplices an inner generator in transparently. Sends and throws forward; the inner'sreturnvalue becomes the value ofyield from.awaitis essentially this with new syntax.- asyncio's one suspension is
yield selfinFuture.__await__. The Task pulls the Future out ofsend, parks on it, and resumes when the Future resolves.sendcarries scheduling; the Future carries the data.
With the protocol in hand, the second cancellation model — anyio's — stops looking magical and starts looking like a careful application of three things: throw, the Future a Task is parked on, and the rule that no task outlives the block that spawned it. Part 5 builds it.