Cancellation is the part of asyncio that decides whether your library is safe
to use in a real program. Timeouts cancel. A failing sibling in a TaskGroup
cancels. A user closing a browser tab cancels. If your code leaks a connection
each time, it leaks under exactly the conditions that already mean something is
wrong.
What cancelling actually does
task.cancel() doesn’t stop anything immediately. It arranges for
CancelledError to be raised inside the task, at its current await point,
the next time the loop runs it.
import asyncio
log = []
async def work():
log.append("started")
try:
await asyncio.sleep(1)
log.append("finished normally")
except asyncio.CancelledError:
log.append("cancelled here")
raise
finally:
log.append("cleanup ran")
async def main():
task = asyncio.create_task(work())
await asyncio.sleep(0.01) # let it reach the sleep
log.append("calling cancel()")
task.cancel()
log.append("cancel() returned, nothing has happened yet")
try:
await task
except asyncio.CancelledError:
log.append("await task raised CancelledError")
print("\n".join(log))
print("task.cancelled():", task.cancelled())
asyncio.run(main())Note the order: cancel() returns before the task has noticed. Cancellation is
a request, delivered at the next opportunity, and the task gets to run its
except and finally blocks on the way out.
CancelledError is not an Exception
This is the detail that causes the bugs. Since Python 3.8:
class CancelledError(BaseException): ...
So except Exception does not catch it — which is deliberate, because
cancellation should propagate. The damage is in cleanup code written as:
try:
value = await source.read()
except Exception:
await source.close() # never runs when cancelled
raise
await source.close()
return value
import asyncio
open_count = 0
class Source:
async def open(self):
global open_count
open_count += 1
async def close(self):
global open_count
open_count -= 1
async def read(self):
await asyncio.sleep(1)
return "data"
async def careless():
source = Source()
await source.open()
try:
value = await source.read()
except Exception: # CancelledError is not an Exception
await source.close()
raise
await source.close()
return value
async def careful():
source = Source()
await source.open()
try:
return await source.read()
finally: # runs whatever happens
await source.close()
async def cancel_after_start(coroutine):
global open_count
open_count = 0
task = asyncio.create_task(coroutine)
await asyncio.sleep(0.01)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
return open_count
async def main():
print("careless leaves open:", await cancel_after_start(careless()))
print("careful leaves open:", await cancel_after_start(careful()))
asyncio.run(main())One source left open per cancelled read, in code that looks like it handles
errors. The fix is finally, or the async with that wraps it up for you —
which is why today’s library does the work inside async with Source(...).
Never swallow a CancelledError
except asyncio.CancelledError:
pass # almost always wrongA task that refuses cancellation hangs whoever is waiting for it — including a
TaskGroup that is trying to shut down, which then waits forever. If you catch
it to clean up, always raise again.
Cleanup that awaits
finally runs during cancellation, but an await inside it can itself be
cancelled — by a second cancellation, or by a timeout you wrap the cleanup in.
(The CancelledError that triggered the finally isn’t re-raised into it; the
risk is a fresh interruption arriving while the cleanup is awaiting.) For a quick
local close that’s fine — source.close() doesn’t await anything that would be
interrupted. For a “tell the server we’re going” that involves I/O, you have to
decide what “must complete” actually means, and the tempting recipe gets it
wrong:
finally:
with contextlib.suppress(TimeoutError):
async with asyncio.timeout(2):
await asyncio.shield(source.goodbye()) # does NOT do what it looks like
shield protects goodbye() from the timeout’s cancellation — which is the
problem. When the deadline fires, the cancellation hits the await, so control
leaves the block, but the shielded goodbye() keeps running, now detached, with
nobody awaiting it. You didn’t bound the cleanup; you abandoned it, and left a
task running past the finally.
There are two honest options, and neither is the recipe above:
-
Bound it and mean it. Drop the shield.
asyncio.timeoutthen cancelsgoodbye()itself at the deadline, so nothing leaks — at the cost that a slow goodbye is cut off. That’s usually the right trade in cleanup.finally: with contextlib.suppress(TimeoutError): async with asyncio.timeout(2): await source.goodbye() -
Let it outlive this scope on purpose. If the goodbye genuinely must finish even though this task is being cancelled, hand it to something that lives longer — a task owned by the pipeline or the application — and let that own its completion and cancellation. Don’t pretend the
finallywaited for it.
shield does not make an await uncancellable
asyncio.shield(coro) protects coro from an outer cancel, but the await
on the shield is still cancellable — and a second cancellation, or a timeout,
lands on the waiter while the shielded work carries on. Shielding is for “this
inner step should survive my caller cancelling me”, not “wait no matter what”.
If you shield something, you are now responsible for its lifetime; keep the
task and cancel-and-await it, or you have created exactly the leak this day is
about.
Timeouts are cancellations with a name
asyncio.timeout(2) cancels its block at the deadline and converts the
CancelledError into TimeoutError as it leaves. That conversion is why you
catch TimeoutError outside and CancelledError inside — and why cleanup that
only handles Exception misses timeouts too.
Shutting down what you started
Anything created with create_task outside a TaskGroup is yours to stop. Say
you started a background worker that loops forever — the code that starts it has
to end it:
worker = asyncio.create_task(run_forever())
try:
... # use it
finally:
worker.cancel()
await asyncio.gather(worker, return_exceptions=True)
Both lines are needed. cancel() requests; gather(..., return_exceptions=True)
waits for the task to actually finish unwinding and absorbs the
CancelledError that comes back. Skip the wait and the program can exit while
the task is still in its finally; skip the absorb and shutdown raises the
cancellation you asked for.
Better still: keep it in a TaskGroup
This is the pattern to reach for only when a task genuinely must outlive the
block that spawns it. When it doesn’t, put it in a TaskGroup and let the
group own its lifetime — which is exactly what Day 5’s run_pipeline does with
its consumer, so it never has a loose create_task to chase. A background task
you manage by hand is a background task you can forget to stop.
Shut the pool down cleanly
WorkerPool starts background workers that pull from a queue. Finish
aclose() so that it stops them, waits for them, and leaves nothing running —
even when a worker is in the middle of a job.
import asyncio
import unittest
started, finished, cleaned = [], [], []
class WorkerPool:
def __init__(self, workers=2):
self.queue = asyncio.Queue()
self.tasks = [asyncio.create_task(self.work(index)) for index in range(workers)]
async def work(self, index):
try:
while True:
job = await self.queue.get()
try:
started.append(job)
await asyncio.sleep(0.05)
finished.append(job)
finally:
self.queue.task_done()
finally:
cleaned.append(index)
def submit(self, job):
self.queue.put_nowait(job)
async def aclose(self, *, drain=False):
"""Stop the workers.
drain=True: let the queued jobs finish first, then stop.
drain=False: stop now, even mid-job.
Either way, no task may be left running when this returns.
TODO: write it.
"""
raise NotImplementedError("aclose")
class Pool(unittest.TestCase):
def setUp(self):
started.clear(); finished.clear(); cleaned.clear()
def test_draining_finishes_the_queued_jobs(self):
async def scenario():
pool = WorkerPool(workers=2)
for job in range(4):
pool.submit(job)
await pool.aclose(drain=True)
return [task for task in pool.tasks if not task.done()]
self.assertEqual(asyncio.run(scenario()), [])
self.assertEqual(sorted(finished), [0, 1, 2, 3])
def test_stopping_now_abandons_work_in_progress(self):
async def scenario():
pool = WorkerPool(workers=2)
for job in range(4):
pool.submit(job)
await asyncio.sleep(0.01) # jobs 0 and 1 are in flight
await pool.aclose(drain=False)
return [task for task in pool.tasks if not task.done()]
self.assertEqual(asyncio.run(scenario()), [])
self.assertEqual(finished, [])
self.assertEqual(sorted(cleaned), [0, 1], "both workers should have unwound")
def test_closing_twice_is_harmless(self):
async def scenario():
pool = WorkerPool(workers=1)
await pool.aclose()
await pool.aclose()
asyncio.run(scenario())
unittest.TextTestRunner(verbosity=2).run(
unittest.defaultTestLoader.loadTestsFromTestCase(Pool)
)Hint 1: the idea
Four requirements, one step each: when draining, wait for the queue’s backlog
first; cancel every worker; wait for the cancellations to actually finish;
and forget the tasks, so closing twice is harmless. The waiting mustn’t turn
the workers’ CancelledErrors into an error for whoever called aclose.
Hint 2: a first step
Cancel each task, then gather them — and look for the gather argument that
stops a CancelledError from coming back out of aclose. Get the tests
without draining passing first, then add drain: queue.join() waits for the
backlog.
Show one solution
async def aclose(self, *, drain=False):
if drain:
await self.queue.join() # wait for the backlog
for task in self.tasks:
task.cancel()
await asyncio.gather(*self.tasks, return_exceptions=True)
self.tasks = []Four lines, one per requirement. Drain first if asked, cancel everything,
wait for the cancellations to complete, and forget the tasks so a second
aclose() does nothing.
return_exceptions=True is what makes this not raise: every worker ends with
CancelledError, and without it gather re-raises the first one at the caller
— turning an orderly shutdown into an error.
self.tasks = [] is why closing twice is harmless. Idempotent cleanup matters
more than it sounds: aclose will be called from a finally somewhere, and
that finally may itself run during an error path where it has already been
called once.
Note what drain=True relies on: task_done() in the worker’s inner
finally. queue.join() returns when the count of unfinished tasks reaches
zero, so a worker that forgets to call task_done makes join() hang forever
— a deadlock in shutdown code, which is the worst place for one.
What you learned
cancel()requests cancellation; it’s delivered at the task’s next await, andexcept/finallystill run.CancelledErrorinherits fromBaseException, soexcept Exceptionmisses it — usefinallyorasync withfor cleanup, and always re-raise.asyncio.shieldprotects an inner step from an outer cancel but leaves theawaitcancellable; to bound cleanup, use a timeout without a shield, or own the shielded task’s lifetime yourself.- Anything you
create_taskyou must cancel and await, absorbing theCancelledError.