Cancellation correctness is the difference between a service that drains in two seconds on SIGTERM and one that gets SIGKILLed after thirty and loses whatever was in flight. It is also the part of asyncio most likely to be written by copying a snippet.
CancelledError is a protocol
Task.cancel() does not stop anything. The docs are precise: it “arranges for a
CancelledError to be thrown into the wrapped coroutine on the next cycle
of the event loop”. It returns False if the task is already done. The
coroutine then “has a chance to clean up or even deny the request”.
So cancellation is a conversation. The canceller says “please stop”; the coroutine gets an exception at its next suspension point; what happens next is the coroutine’s decision. Three consequences:
-
Code with no
awaitin it cannot be cancelled. A tight CPU loop ignorescancel()completely, because there is no suspension point to throw into. This is the same non-interruptibility that makes threads uncancellable, arriving through a different door. -
finallyblocks run. That is the entire value proposition: unlike killing a thread, cancelling a task unwinds the stack properly, so locks are released and context managers exit. - You can decline. The docs also say “suppressing cancellation completely is not common and is actively discouraged” — because the canceller usually has a deadline, and a task that declines is a task that gets abandoned or killed harder.
Why it inherits BaseException
In Python 3.8, CancelledError moved from Exception to BaseException. That
one change is the difference between cancellation working and cancellation
being a coin flip, because of this:
try:
await do_work()
except Exception:
log.exception("work failed")
return DEFAULT
Before 3.8 that swallowed cancellation. The task would be told to stop, catch
the CancelledError as an ordinary error, log it as a failure, return a
default, and carry on — and the canceller would wait for a task that had
decided not to stop. After 3.8, except Exception does not match, and the
cancellation continues to propagate.
The rule that falls out: except BaseException in async code must re-raise,
or you have to handle CancelledError explicitly. Every except BaseException
that does not end in raise is a cancellation bug.
💡Your finally needs to await — release a lease, flush a buffer. Does that work while the task is being cancelled?
click to reveal
Yes, and this is the detail that makes bounded cleanup possible at all.
asyncio’s cancellation is edge-triggered: cancel() delivers exactly one
CancelledError. Once it has been delivered, the task’s _must_cancel flag is
clear, so a subsequent await in a finally or except block suspends and
resumes normally rather than immediately re-raising.
(trio and anyio are level-triggered by contrast: a cancelled scope raises at
every checkpoint inside it, so an await in a finally needs an explicit
shielded scope. Both designs are defensible — trio’s makes it impossible to
accidentally escape cancellation, asyncio’s makes cleanup natural — but they
are opposite, which is why mixing the two paradigms goes badly.)
The catch is that “cleanup can await” also means “cleanup can hang forever”,
and now you are ignoring the cancellation you accepted. That is why cleanup
needs its own bound: async with asyncio.timeout(...) around it, so a slow
cleanup costs you a fixed amount and then gives up.
Note also that a second cancel() will interrupt cleanup, which is exactly
how a shutdown sequence escalates.
cancelling() and uncancel()
Python 3.11 added a counter to Task. cancel() increments it; uncancel()
decrements it. Neither is for application code — they exist so that constructs
which cancel their own block can tell “I did this” from “someone cancelled me
from outside”.
asyncio.timeout is the canonical user. On expiry it calls self._task.cancel()
and, in __aexit__, checks whether uncancel() brings the count back to what
it was on entry. If so, the cancellation was its own, and it converts the
CancelledError into TimeoutError. If the count is still higher, an outer
cancellation is also in flight, and it lets the CancelledError through
untouched.
This is what makes timeouts nest correctly, and what lets a bounded cleanup run inside a task that has already been cancelled from outside. You will rarely call these methods; you rely on them constantly.
shield() is usually a bug
asyncio.shield(coro) protects an awaitable from the cancellation of its
awaiter. The documentation’s own example is captioned “completely ignore
cancellation (not recommended)”.
Three reasons to be suspicious of it:
-
It does not stop the shielded work, it only detaches you from it. The
awaiter gets
CancelledError; the shielded task keeps running with nobody waiting for it. That is exactly thegatherleak. -
It carries the weak-reference hazard.
shieldwraps its argument in a task internally, so the thing you shielded can be garbage-collected if nothing else holds it. -
The real requirement is almost always “bounded”, not “unbounded”. You
want the flush to get a chance, not a licence. A
finallywithasyncio.timeoutgives you the chance and keeps the deadline.