Part 4 of 5

Two Ways to Cancel

anyio is the discipline layer over asyncio — real checkpoints, cancel scopes you can compose, and the rule that no task outlives the block that spawned it. To see why it's different, start one level down: at the protocols await is made of.

~16 min read Python 3.11+ · anyio 4.x Assumes: Parts 2 & 3

You have written async with a thousand times. Here is what the two words cost, mechanically — and why the answer is the difference between code that cancels cleanly and code that leaks a task you'll find on a dashboard three weeks later.

Part 2 built the asyncio event loop from its parts: _ready, _scheduled, _run_once, coroutines as resumable functions. Part 3 stacked a server on top. This post fills the gap both of them stepped over — the protocols that await, async with, and async for desugar into, and the second cancellation model that anyio layers on top of all of it. Starlette, FastAPI, HTTPX, and every modern async library run on this layer. The leak in the SSE field report is a direct consequence of it. You cannot read that code without this.

From Go

Go's runtime parks a goroutine on a channel receive and the scheduler does the bookkeeping invisibly. Python makes the bookkeeping a language-level protocol you can see and implement: a coroutine hands a Future up to whatever is driving it and says "wake me when this resolves." anyio's cancel scope is then a second, trio-derived idea bolted on — cancellation as a property of a lexical block, not an exception aimed at a goroutine. Two models, and you need both in your head.

§1Three protocols, three keywords

Three pieces of async syntax exist, and each is pure sugar over a dunder protocol. No magic, just method calls the interpreter makes for you.

You writeInterpreter runsProtocol
await xyield from x.__await__()__await__(self) → iterator
async with xawait x.__aenter__() … await x.__aexit__(…)__aenter__, __aexit__
async for v in xawait x.__anext__() until StopAsyncIteration__aiter__, __anext__

So async with anyio.create_task_group() as tg: is, with the sugar removed:

tg = anyio.create_task_group()
tg = await tg.__aenter__()
try:
    ...                       # the body of the `with`
finally:
    await tg.__aexit__(*sys.exc_info())

The two enter/exit calls are themselves await-ed, which is the detail §3 turns on. But first: what does await actually do at the bottom of the stack? Because "await a coroutine" is recursive — coroutines await coroutines await coroutines — and somewhere at the very bottom is one thing that isn't a coroutine at all. That thing is where suspension is born.

§2The one true suspension

await x evaluates x.__await__(), which returns an iterator. The Task driving your coroutine pumps that iterator with .send(). Every value the iterator yields is a suspension — control goes back to the event loop. When the iterator returns, that return value is the value of the await expression. That is the entire contract.

Almost everything you await is a coroutine, and a coroutine's __await__ just delegates downward. So follow the chain to the floor. The floor is asyncio.Future — real CPython:

# cpython/Lib/asyncio/futures.py
def __await__(self):
    if not self.done():
        self._asyncio_future_blocking = True
        yield self  # This tells Task to wait for completion.
    if not self.done():
        raise RuntimeError("await wasn't used with future")
    return self.result()  # May raise too.

The single yield self is the only way anything in asyncio suspends. Everything else bottoms out here.

That one yield self is the whole trick of asyncio. asyncio.sleep, Queue.get, lock acquisition, a socket read — all of them, eventually, await a Future, and the Future yields itself up to the Task. The Task sees a Future come out of the .send(), attaches a done-callback to it, and stops pumping. The coroutine is now parked. When the Future is resolved (by a timer firing, a socket becoming readable, another task calling set_result), the callback reschedules the Task, which resumes the coroutine by sending the result back in. The yield self is the park; the Future is the parking spot.

Diagram A · how a coroutine parks

A chain of awaits bottoms out at Future.__await__, whose yield self hands the Future up to the Task. The Task parks the coroutine on that Future and returns control to the loop. Resolution reschedules the Task.

your coroutine await q.get() Future.__await__ yield self await ↓ Task (the driver) _fut_waiter = fut yields Future up event loop runs others parks, returns Future resolved → done-callback reschedules Task → resume

Hold onto _fut_waiter. It is the attribute on the Task that holds the Future a parked coroutine is waiting on. anyio's cancellation reads it directly to decide whether a task is parked and therefore cancellable — we'll see exactly that in §4. The protocol from Part 2 and the cancellation machinery in §4 are the same machinery viewed from two ends.

§3async does not mean "yields"

Here is the misconception that cost me a paragraph of wrong explanation in an earlier draft of the field report. It is natural to assume that because __aenter__ is async and you await it, entering an async with suspends — bounces off the loop. It does not. The async keyword grants a function the right to suspend. It does not make it suspend. A suspension happens only if, somewhere inside, an await reaches a yield self on an unresolved Future.

anyio's task group is the proof. Its __aenter__ — real source:

# anyio/_backends/_asyncio.py — TaskGroup
async def __aenter__(self) -> TaskGroup:
    self.cancel_scope.__enter__()   # synchronous! no await inside
    self._active = True
    return self

The body awaits nothing. cancel_scope.__enter__() is an ordinary synchronous method. So await tg.__aenter__() runs start to finish without ever yielding a Future — the loop never gets a turn, no other task runs, control never leaves your coroutine. Entering a task group is, for scheduling purposes, a function call with extra syntax.

Misconception

"It's async, so awaiting it yields to the loop." No. Awaiting a coroutine that never reaches an unresolved Future runs synchronously to completion. await is not a yield point; await of something that parks is. This distinction is the whole of §5 and §6 — and the reason a "cheap peek" can or cannot work.

So if entering doesn't suspend, where does a task group suspend? At __aexit__ — when it has to wait for the children it spawned. That's §6. First, the cancellation model that makes the wait interesting.

§4anyio's other cancellation

Part 2 taught asyncio cancellation: task.cancel() schedules a CancelledError to be thrown into the coroutine at its next resume. It is aimed at a task. You cancel a thing.

anyio inherits a different model from trio: cancellation is a property of a scope, a lexical region. You don't cancel a task; you cancel a block, and every task currently executing inside that block gets unwound at its next checkpoint. A CancelScope is an object you can hold, pass around, and — crucially — cancel before anyone has entered it. Timeouts are just a scope with a deadline.

with anyio.CancelScope() as scope:
    ...                      # everything here is cancellable as a unit
    scope.cancel()             # trip it; unwinding happens at the next checkpoint

with anyio.move_on_after(5.0):    # a scope with a deadline
    await something_slow()

The word checkpoint is anyio's, and it is precise. A checkpoint is a point where two things happen: the current scope is checked for cancellation, and the scheduler is given a chance to switch tasks. anyio's own checkpoint() documents it exactly:

# anyio/lowlevel.py
async def checkpoint() -> None:
    """Check for cancellation and allow the scheduler to switch to another task."""
    await get_async_backend().checkpoint()

Now the mechanism that makes the next section work. When you call scope.cancel(), how does the cancellation reach a task? Not instantly. anyio schedules it. Real source, trimmed to the load-bearing lines:

# anyio/_backends/_asyncio.py — CancelScope
def _deliver_cancellation(self, origin):
    current = current_task()
    for task in self._tasks:
        if task is not current and ...:   # never cancels the running task inline
            waiter = task._fut_waiter
            if not isinstance(waiter, asyncio.Future) or not waiter.done():
                task.cancel(origin._cancel_reason)
    ...
    if origin is self and should_retry:
        self._cancel_handle = get_running_loop().call_soon(
            self._deliver_cancellation, origin)      # try again next loop tick

Two load-bearing facts: it skips the currently-running task (task is not current), and if work remains it reschedules itself with call_soon.

Read the two highlighted lines together. _deliver_cancellation refuses to cancel the task that is currently running — the one that just called cancel(). Instead, if there's still a task eligible for cancellation, it reschedules itself via loop.call_soon to run again on the next loop tick. So calling cancel() from inside the running task doesn't raise anything then and there. It arms a callback. The cancellation actually lands the next time the task suspends — parks on a Future, _fut_waiter gets set — and the rescheduled _deliver_cancellation finds it parked and calls task.cancel() on it.

Cancellation in anyio isn't thrown at you. It's scheduled — and it lands the next time you blink.

"The next time you blink" = the next suspension. If you never suspend — if you run straight through to the end of the scope without awaiting anything that parks — the call_soon'd callback never gets its turn before the scope exits, and the cancellation is simply dropped. That asymmetry is not a bug. It is a feature one clever piece of Starlette weaponizes.

§5The pre-armed scope

Put §3 and §4 together and you can build something that looks impossible: a non-blocking peek at a queue whose only interface is a blocking get. Starlette's Request.is_disconnected does exactly this — real source, and it is shorter than its reputation:

# starlette/requests.py (1.0.0)
async def is_disconnected(self) -> bool:
    if not self._is_disconnected:
        message: Message = {}

        # If message isn't immediately available, move on
        with anyio.CancelScope() as cs:
            cs.cancel()                     # pre-armed: tripped before the await
            message = await self._receive()

        if message.get("type") == "http.disconnect":
            self._is_disconnected = True
    return self._is_disconnected

Trace it with §4 in hand. The scope is entered, then cs.cancel() trips it — arming the call_soon callback, raising nothing yet. Then await self._receive(). Two outcomes:

That is a genuinely beautiful use of the model: "give me the message if you can hand it over without making me wait; otherwise pretend I never asked." It is queue.peek() synthesized out of queue.get() plus a pre-armed cancel scope. It works precisely because of the §4 asymmetry — cancellation that lands only on suspension — and the §3 fact that a synchronous return never suspends.

Mental model

A pre-armed cancel scope is a bet on whether the next await parks. Win the bet (synchronous return) and you keep the value. Lose it (real suspension) and you exit empty-handed but unharmed. The whole construct is safe only because anyio defers cancellation delivery to the next checkpoint instead of raising it inline.

The field report's entire §3–§4 is the story of this peek breaking — not because the trick is wrong, but because something downstream forces a suspension that the trick reads as "no message." To see how a suspension can be forced where you'd swear none exists, we need the last piece: what a task group does on the way out.

§6Why the join is the checkpoint

§3 showed that entering a task group doesn't suspend. Exiting is the opposite: a task group cannot exit until every child it spawned has finished. That is the entire promise of structured concurrency — no task outlives the block that spawned it. Honoring the promise means __aexit__ has to wait, and waiting means awaiting a Future, and awaiting a Future is a suspension. Real source:

# anyio/_backends/_asyncio.py — TaskGroup.__aexit__ (trimmed)
if self._tasks:
    with CancelScope() as wait_scope:
        while self._tasks:
            self._on_completed_fut = loop.create_future()
            await self._on_completed_fut      # suspends until a child completes

If any child task is still live when control reaches the end of the async with block, __aexit__ creates a Future and awaits it — parking the host task until the last child finishes. That await is the checkpoint that was missing from the enter. A task group's cost is paid on exit, not entry.

This is why the pre-armed peek of §5 fails through Starlette's BaseHTTPMiddleware, which the next part dissects in full. The short version: the middleware doesn't hand the raw receive channel down — it hands down a wrapper that spawns a sibling task in a group and races it against the real receive. Even when the real receive has a message ready and returns synchronously, the group still has a sibling to wind down, so __aexit__ awaits the join — and that forced suspension is exactly where the pre-armed cancellation finally lands. The peek that should have returned a message returns nothing instead.

Diagram B · enter is free, exit is a checkpoint

Entering a task group runs synchronously — no loop bounce. If a child is still running at the end of the block, __aexit__ must await its completion; that await is the suspension a pre-armed scope will fire on.

host task __aenter__ (sync) body + start_soon __aexit__: await join ↑ checkpoint — suspends here sibling task runs… completes spawned completion wakes the join

§7Memory streams have a back wall

One more anyio primitive, because the field report's "buffering" claim rests on it and Part 5 leans on it hard. asyncio gives you Queue(maxsize=…). anyio gives you the memory object stream: a sender half and a receiver half, with a buffer between them. The shape that matters is send:

# anyio/streams/memory.py — MemoryObjectSendStream (trimmed)
def send_nowait(self, item):
    ...
    if len(self._state.buffer) < self._state.max_buffer_size:
        self._state.buffer.append(item)   # room → returns immediately
    else:
        raise WouldBlock                     # full → caller must await send()

With max_buffer_size=0 (the default) the buffer has no room ever: every send blocks until a receiver is waiting to take the item directly. That's a rendezvous — pure backpressure, the sender can never outrun the receiver. With a positive size, the sender may run ahead by that many items before it's forced to wait. Either way there is a back wall: the producer cannot push unboundedly. This is the property a plain unbounded Queue lacks, and it is the reason Starlette routes streaming-response bytes through a memory stream — to couple the producer's pace to the client's. The failure mode in Part 5 is what happens when that coupling sits behind a layer that has stopped reading.

§8Take with you

Up next · Part 5 of 5

You now have the two cancellation models and the protocols underneath them. Part 5 spends them: a Starlette middleware is an ASGI app wrapping an ASGI app, a Response is an ASGI app too, and BaseHTTPMiddleware bridges the two with a task group and a memory stream — the exact construction that makes §5's peek lie and swallows client disconnects on streaming responses.