11.5. Timeouts and cancellation¶
Cancellation is asyncio’s name for “stop running this
coroutine, raise an exception inside it so it has a chance
to clean up, and remove it from the scheduler”. Timeouts are
the most common reason to cancel something; manual
task.cancel() is the other.
11.5.1. Cancelling a task¶
Calling Task.cancel schedules
asyncio.CancelledError to be raised inside the
running coroutine at its next await. The coroutine is
free to ignore the cancellation (catch the exception and
keep running) or to honour it – the usual choice is to
honour it after running cleanup code:
async def network_worker(stream):
try:
while True:
data = await stream.read(64)
# ...
finally:
stream.close()
A bare finally clause is the cleanest pattern: the
cleanup runs whether the coroutine exited normally, raised
an unrelated exception, or was cancelled. The
CancelledError propagates back up through
the finally, the loop sees the task is done, and the
caller of await task sees the cancellation.
Catching CancelledError explicitly is also
fine when the application wants to do something specific
with it – log it, hand the resource off cleanly, etc. The
rule is: re-raise it after the cleanup runs. Swallowing
CancelledError keeps the coroutine alive
when its caller has asked it to stop, which is almost
always a bug:
async def worker():
try:
await long_running_thing()
except asyncio.CancelledError:
log("worker cancelled, cleaning up")
close_resources()
raise # << this line is the important one
11.5.2. Timeouts with wait_for¶
asyncio.wait_for() wraps an awaitable in a deadline.
If the awaitable finishes within the timeout, its result is
returned. If it does not, the awaitable is cancelled and the
caller gets asyncio.TimeoutError:
try:
frame = await asyncio.wait_for(grab_frame(), timeout=2)
except asyncio.TimeoutError:
print("camera took too long")
The seconds argument accepts a float. For millisecond-shaped
deadlines asyncio.wait_for_ms() takes an integer
millisecond count – a MicroPython extension that lines up
with the firmware’s millisecond-grained timing knobs.
Internally, wait_for does exactly what manual
cancellation would do: when the deadline expires it calls
cancel() on the wrapped task,
CancelledError is raised inside the
coroutine, finally clauses run, and once the cleanup is
done the exception is translated into a
TimeoutError for the caller.
That means a coroutine that catches and ignores
CancelledError will defeat the timeout –
the deadline expired, but the coroutine refused to stop, and
wait_for cannot force it. The earlier rule applies here
too: catch CancelledError only to run
cleanup, then re-raise.
11.5.3. Cancellation through gather¶
Cancellation propagates downward through
gather(). If the task that is awaiting a
gather call is cancelled, every awaitable still running
inside the gather is cancelled too – each one gets a chance
to clean up through its own finally clauses before the
cancellation rolls up to the caller.
Combined with timeouts, this is the standard way to put a deadline on a group of operations:
await asyncio.wait_for(
asyncio.gather(a(), b(), c()),
timeout=5,
)
Either every sub-operation finishes within five seconds, or all of them get cancelled together.
11.5.4. Shutting an application down¶
Cancellation is also how a real application stops cleanly.
The pattern is consistent across scripts: main captures
the handles for the long-lived background tasks it started,
runs its top-level work, then cancels each handle and
awaits it in a finally block:
async def main():
sender = asyncio.create_task(uplink())
watcher = asyncio.create_task(button_watcher())
try:
await snapshot_loop()
finally:
sender.cancel()
watcher.cancel()
await asyncio.gather(sender, watcher,
return_exceptions=True)
The return_exceptions=True is the trick that keeps the
gather from re-raising the CancelledError
each child task is about to deliver, so the application’s
own exit reason – whatever snapshot_loop did or did not
raise – is what bubbles out of main.