Implementing a Cancel Scope in asyncio¶
Trio and AnyIO have cancel scopes: a with block that can be cancelled as a unit — by a deadline or an explicit call — after which the task continues with the code that follows. asyncio cancels whole tasks, but since Python 3.11 it has the pieces to build a scope, and asyncio.timeout is one already. The difficulty is telling the scope's own cancellation apart from someone else's. Measured on Python 3.14: a naive scope that suppressed any CancelledError after cancel() was called completed the task normally even when the task had also been cancelled from outside at the same moment — the outside cancellation was lost. A scope that recorded task.cancelling() on entry and called task.uncancel() on exit continued after its own cancellation and propagated the outside one. Nested scopes behaved correctly: cancelling the outer one skipped the rest of both blocks. Entering and leaving the scope cost 0.12 µs; asyncio.timeout(None), which can serve as a scope through reschedule(), cost 0.39 µs and also propagated outside cancellation. This guide builds the scope and tests the cases that break the naive one.
Prerequisites¶
- Python 3.11+ for
Task.cancelling()andTask.uncancel(). - The counters, from understanding Task.cancelling and uncancel.
- The topic overview, Cancellation Patterns.
1. Start from asyncio.timeout¶
The standard library already has a scope: asyncio.timeout(None) creates a timeout with no deadline, and reschedule() sets one later. Rescheduling to the current time cancels the block now:
async def fetch_until_told(source, stop_event):
results = []
try:
async with asyncio.timeout(None) as scope:
loop = asyncio.get_running_loop()
stop_event.add_callback(lambda: scope.reschedule(loop.time())) # "cancel the block"
async for item in source:
results.append(item)
except TimeoutError:
pass # the block was cancelled; carry on
return results # code after the block still runs
Measured: when the block was cancelled at 0.1 s, the TimeoutError was caught and the task continued and finished. When the task was also cancelled from outside at 0.1 s, it ended with CancelledError, as it should — asyncio.timeout distinguishes its own cancellation from others. The drawback is the exception type: callers see TimeoutError for something that was not a timeout, and nested library code that catches TimeoutError can mistake it for a real one.
Verify: a scope built on asyncio.timeout is tested with an outside cancellation arriving at the same time as the scope's own.
2. See how a naive scope loses cancellations¶
A first attempt at a dedicated scope cancels the current task and suppresses CancelledError on the way out:
class NaiveScope:
def __enter__(self):
self.task = asyncio.current_task()
self.requested = False
return self
def cancel(self):
self.requested = True
self.task.cancel()
def __exit__(self, exc_type, exc, tb):
return exc_type is asyncio.CancelledError and self.requested # swallow ours
Measured: with only the scope cancelling, the task continued after the block and finished — correct. With the task also cancelled from outside at the same 0.1 s, the scope swallowed the CancelledError and the task completed normally: the outside cancellation, perhaps a shutdown or a TaskGroup tearing down its children, simply vanished. The scope cannot tell from the exception alone how many cancellations it represents.
Verify: a test where both the scope and an outside caller cancel ends with the task cancelled, not completed.
3. Count cancellations with cancelling and uncancel¶
Each task.cancel() increments the task's cancellation counter, task.cancelling(); task.uncancel() decrements it and returns the new value. A scope that records the counter on entry can tell, on exit, whether any cancellation besides its own is pending:
class CancelScope:
def __enter__(self):
self.task = asyncio.current_task()
self.requested = False
self.cancelled_caught = False
self._cancelling_on_entry = self.task.cancelling()
return self
def cancel(self):
if not self.requested:
self.requested = True
self.task.cancel(msg=f"cancel scope {id(self):x}")
def __exit__(self, exc_type, exc, tb):
if not self.requested:
return False
remaining = self.task.uncancel() # retract our own request
if exc_type is asyncio.CancelledError and remaining <= self._cancelling_on_entry:
self.cancelled_caught = True
return True # only ours was pending: swallow it
return False # someone else's too: propagate
Measured: with only the scope cancelling, the task continued and finished; with an outside cancellation as well, the counter showed two requests, the scope retracted one, and CancelledError propagated to the caller. The if not self.requested guard in cancel() keeps repeated calls from incrementing the counter more than once.
Verify: after a scope exits, task.cancelling() equals its value on entry when only the scope cancelled.
4. Nest scopes¶
Scopes nest the way with blocks do. Cancelling an outer scope must skip the rest of the inner block and the rest of the outer one, and be caught by the outer scope only:
with CancelScope() as outer:
with CancelScope() as inner:
loop.call_later(0.1, outer.cancel)
await asyncio.sleep(1)
log("after inner") # skipped: the outer scope is cancelled
log("after outer") # runs
Measured: "after inner" did not run, "after outer" did, outer.cancelled_caught was True and inner.cancelled_caught was False. The inner scope never requested a cancellation, so it let the exception pass; the outer scope recognised its own. The same counting keeps a scope inside a TaskGroup child from swallowing the group's cancellation of that child.
Verify: nested-scope tests cover cancelling the inner scope, the outer scope, and the task from outside.
5. Add deadlines and use it¶
A cancel scope becomes a deadline scope with a timer handle that calls cancel():
class DeadlineScope(CancelScope):
def __init__(self, seconds: float):
self.seconds = seconds
def __enter__(self):
super().__enter__()
self._handle = asyncio.get_running_loop().call_later(self.seconds, self.cancel)
return self
def __exit__(self, *exc):
self._handle.cancel()
return super().__exit__(*exc)
async def best_effort_enrich(item):
with DeadlineScope(0.2) as scope:
item.extra = await slow_lookup(item.id)
if scope.cancelled_caught:
item.extra = None # skipped, not failed
return item
The pattern fits optional work: enrichment, prefetching, a cache refresh — anything the caller would rather skip than wait for. Measured: a 0.2-second DeadlineScope around a 1-second sleep was caught after 0.2 s with cancelling() back to 0, and one whose work finished first left cancelled_caught as False. Entering and leaving a scope cost 0.12 µs, so scopes can wrap fine-grained work. For full Trio-style semantics — shielding, scopes spanning task groups, level-triggered cancellation — use AnyIO, as in running subprocesses with AnyIO and the rest of that topic; the scope here covers the common single-task case.
Verify: code using cancelled_caught treats the skipped work as absent, and a test confirms an outer deadline still cancels the task.
Verification¶
A cancel scope is correct when:
- Its own cancellation is caught and the task continues after the block.
- Any other cancellation propagates, tested with both arriving at once.
- Nested scopes catch only their own cancellations.
task.cancelling()is back to its entry value after a scope that caught its own cancellation.
Diagnostic Hook: when a service ignores shutdown or a TaskGroup waits on a child that should have been cancelled, look for code that catches CancelledError and decides from a flag whether to re-raise. A naive scope completed its task normally despite an outside cancellation in this test; cancelling() and uncancel() tell the cases apart.
Pitfalls & edge cases¶
- Suppressing
CancelledErrorbased on a flag. Measured: an outside cancel was lost. - Calling
task.cancel()twice from one scope. The counter rises twice; guard it. - Using
asyncio.timeoutas a scope. Callers seeTimeoutErrorfor a non-timeout. - Expecting shielding. This scope does not shield; AnyIO's does.
Frequently Asked Questions¶
Does asyncio have cancel scopes like Trio?
Not as a named API, but asyncio.timeout behaves as one: asyncio.timeout(None) with reschedule(loop.time()) cancels a block and propagated outside cancellation correctly.
How do I cancel part of a coroutine without cancelling the task?
Use a scope that cancels the task, then on exit calls task.uncancel() and suppresses CancelledError only if task.cancelling() is back to its entry value.
Why did my task ignore cancellation?
Code that catches CancelledError and suppresses it — like a naive scope — can swallow an outside cancellation. The naive scope completed its task despite one.
How expensive is a cancel scope?
Entering and leaving the custom scope took 0.12 µs; asyncio.timeout(None) took 0.39 µs.
Related¶
- Cancellation Patterns — up to the topic overview.
- Bounding cleanup time during cancellation — what runs after a scope is cancelled.
- Resilience, Cancellation & Error Handling — the section overview.