Level vs Edge Cancellation in AnyIO and asyncio¶
asyncio and AnyIO both cancel a task by raising an exception at an await, and code written for one usually looks right on the other. The difference is in what happens after that first exception. asyncio cancellation is edge-triggered: task.cancel() delivers one CancelledError, and if the task catches it, later awaits run normally. AnyIO and trio cancellation is level-triggered: once a cancel scope is cancelled, every await inside it raises again until the code leaves the scope. Verified side by side: in asyncio, a task that caught its CancelledError and awaited a 10 ms cleanup ran the cleanup and returned normally; in an AnyIO cancel scope, on both the asyncio and trio backends, the same cleanup await was cancelled again — and ran only inside a shielded scope. Cleanup code that awaits is where porting bugs live.
Prerequisites¶
- Python 3.11+,
pip install anyio trio. - Cancel scopes, from using AnyIO cancel scopes and move_on_after.
- asyncio cancellation, from Cancellation Patterns.
1. Reproduce the difference¶
import asyncio
import anyio
async def asyncio_style() -> str:
async def work():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
await asyncio.sleep(0.01) # cleanup await
return "cleanup ran, returned normally"
t = asyncio.create_task(work())
await asyncio.sleep(0)
t.cancel()
return await t
async def anyio_style() -> list[str]:
out = []
with anyio.CancelScope() as scope:
scope.cancel()
try:
await anyio.sleep(10)
except anyio.get_cancelled_exc_class():
try:
await anyio.sleep(0.01)
out.append("cleanup ran")
except anyio.get_cancelled_exc_class():
out.append("cleanup cancelled again")
raise
return out
print(asyncio.run(asyncio_style())) # cleanup ran, returned normally
print(anyio.run(anyio_style)) # ['cleanup cancelled again']
The asyncio example also shows edge cancellation's main hazard: the task swallowed its cancellation and returned a value, so its canceller cannot tell it was interrupted. Level cancellation makes that impossible inside a scope — any await keeps raising — at the price that legitimate cleanup awaits need explicit protection.
Verify: run both; the outputs differ exactly as shown, and the AnyIO result is the same with backend="trio".
2. Shield cleanup that must await¶
In AnyIO, cleanup that needs to await — closing a connection gracefully, sending a final message, rolling back — goes inside a shielded scope. Shielded scopes are immune to cancellation from outside, so their awaits run; give them their own deadline so they cannot hang:
async def handle(conn) -> None:
try:
await serve_requests(conn)
finally:
with anyio.move_on_after(2, shield=True): # runs even though we are being cancelled
await conn.send_goodbye()
await conn.aclose()
Verified on both backends: the shielded cleanup await ran inside a cancelled outer scope. Without shield=True, the send_goodbye() await raises immediately and the connection is torn down without the goodbye — and aclose() never runs at all.
The asyncio analogue is asyncio.shield(), which protects an inner task rather than a block of code and has different semantics; for code that will run on AnyIO, use shielded scopes rather than asyncio.shield(). The asyncio patterns are in using asyncio.shield to protect critical sections.
Verify: cancel the handler during a request; the peer receives the goodbye and the connection closes cleanly.
3. Stop swallowing cancellation when porting¶
Edge-triggered habits from asyncio become bugs under AnyIO. Three common ones:
# 1. "Log and continue" — asyncio lets the loop keep running; AnyIO cancels the next await anyway
while True:
try:
item = await queue.get()
except asyncio.CancelledError:
log.info("cancelled, continuing") # wrong in both; endless loop in asyncio
continue
# 2. Catching broad exceptions that include cancellation
try:
await fetch()
except BaseException: # catches Cancelled on every backend
metrics.inc("errors")
# 3. Awaiting in except/finally without a shield
try:
await work()
finally:
await flush_logs() # silently skipped under AnyIO when cancelled
Fix them the same way on every backend: always re-raise the cancellation exception, catch Exception rather than BaseException, and put awaiting cleanup in a shielded scope with a timeout. Use anyio.get_cancelled_exc_class() in backend-neutral code, since the class differs: asyncio.CancelledError on asyncio, trio.Cancelled on trio. The asyncio-only version of this checklist is preventing CancelledError leaks in cleanup.
Verify: grep for except asyncio.CancelledError and except BaseException in code that also runs under AnyIO; every hit should re-raise.
4. Understand asyncio's partial move toward levels¶
asyncio 3.11 added pieces that narrow the gap. Task.cancelling() counts pending cancellation requests and Task.uncancel() decrements it; asyncio.timeout() and TaskGroup use them to tell "my own cancellation" from "someone else's". When code catches a CancelledError and does not re-raise, the task's cancelling() count stays above zero, and structured primitives notice:
async def stubborn():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
pass # swallowed
print(asyncio.current_task().cancelling()) # 1 — the request is still recorded
That count is what lets an enclosing asyncio.timeout() re-raise correctly even when inner code misbehaves in some cases. It is still not level-triggered: the next await in stubborn() runs normally. The full semantics are in understanding Task.cancelling and uncancel.
Verify: after swallowing a cancellation, cancelling() is 1 and the next await completes.
5. Write cleanup that is correct under both¶
Backend-neutral libraries should assume level semantics, because code correct under levels is also correct under edges:
import anyio
async def with_connection(pool, fn):
conn = await pool.acquire()
try:
return await fn(conn)
except anyio.get_cancelled_exc_class():
with anyio.CancelScope(shield=True):
await conn.rollback() # protected from the outer cancellation
raise # always propagate
finally:
with anyio.move_on_after(1, shield=True):
await pool.release(conn)
Under asyncio this behaves like careful asyncio code; under trio it behaves like careful trio code. The habits — re-raise, shield awaited cleanup, bound shielded work with a timeout — are the same ones that make plain asyncio code survive TaskGroup and asyncio.timeout() cancellations. For testing across both backends, see testing async code with the AnyIO pytest plugin.
Verify: run the same cancellation test on both backends; both leave the connection rolled back and released.
Verification¶
Cancellation handling is portable when:
- Every caught cancellation is re-raised, using
get_cancelled_exc_class()in neutral code. - Every awaited cleanup sits inside a shielded scope with a timeout.
- No
except BaseExceptionswallows cancellation. - Cancellation tests pass on both backends, not only asyncio.
Diagnostic Hook: in tests, after cancelling a component, assert that its task ended cancelled rather than returning a value; on asyncio, also assert cancelling() == 0 for tasks that should have unwound cleanly. In production, log shielded cleanup that hits its timeout — each one is a resource that may have been left in an unknown state.
Pitfalls & edge cases¶
- Awaiting in
finallywithout a shield under AnyIO. The await raises immediately; the cleanup is skipped. - Unbounded shielded scopes. A hung cleanup now blocks cancellation forever; always add a timeout.
- Mixing
asyncio.shield()into AnyIO code. It protects a separate task, not the current scope, and does not compose with cancel scopes. - Catching
Exceptionand expecting it to catch trio cancellation.trio.Cancelledis aBaseException, likeasyncio.CancelledError.
Frequently Asked Questions¶
What is level-triggered cancellation?
Once a cancel scope is cancelled, every await inside it raises the cancellation exception until the code exits the scope. AnyIO and trio work this way. asyncio is edge-triggered: one cancel() delivers one CancelledError.
Why does my cleanup await fail under AnyIO but work in asyncio?
Under AnyIO's level cancellation, an await inside a cancelled scope raises again, so cleanup awaits in except or finally blocks are cancelled too. Put them in a CancelScope with shield=True, ideally with a timeout.
How do I catch cancellation in backend-agnostic AnyIO code?
Catch anyio.get_cancelled_exc_class(), which returns asyncio.CancelledError or trio.Cancelled depending on the backend, and always re-raise it.
Can asyncio code swallow cancellation?
Yes. If a task catches CancelledError and does not re-raise, it keeps running and can return normally. Task.cancelling() still records the request, which asyncio.timeout and TaskGroup use, but later awaits are not cancelled.
Related¶
- AnyIO & Trio Interop — up to the topic overview.
- Calling sync code from AnyIO with to_thread and from_thread — how cancellation treats worker threads.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.