Structured Concurrency and Cancellation
Task groups that cannot leak a task, timeouts that actually stop work, and treating CancelledError as control flow rather than an error to swallow.
Task groups that cannot leak a task, timeouts that actually stop work, and treating CancelledError as control flow rather than an error to swallow.
The upload handler starts three background tasks and returns.
One of them raises. Nothing catches it, nothing logs it, and the
only trace is a line on stderr at interpreter shutdown saying
Task exception was never retrieved — printed hours later, in a
process that has already handled ten thousand other requests.
Concurrency needs the same discipline as memory: something must own each task, and the owner must know when it finished and whether it failed. By the end of this lesson you will use task groups to guarantee that, know exactly what cancellation does, and be able to write cleanup that survives being cancelled mid-flight.
async def handle(request):
asyncio.create_task(send_receipt(request)) # nobody waits
return responseThree problems, in increasing order of how long they take to find.
The task may be garbage collected before it runs, because the loop holds only a weak reference — so the work sometimes happens and sometimes does not, depending on timing.
Its exception is stored and never retrieved, producing that shutdown message and nothing else.
And nothing cancels it when the program shuts down, so it is killed mid-write, or the process refuses to exit.
The fix is that every task has an owner with a defined lifetime.
Bad — gathering, with failures that leave work running.
async def process_all(paths):
results = await asyncio.gather(
*(process(path) for path in paths)
)
return resultsGood — a group that cancels its siblings and reports everything.
async def process_all(paths):
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(process(p)) for p in paths]
return [task.result() for task in tasks]Thirty-nine tasks, owned by nobody.
The first exception is re-raised to the caller and the rest keep going. Your function has returned and coroutines are still writing to the database — during an error, which is when you least want uncontrolled writes.
And return_exceptions=True, the usual fix, turns failures
into values in a list nobody reads.
Nothing.
The remaining tasks are cancelled, the group waits for every
one of them to finish cancelling, and only then does the
async with block raise.
The block is the lifetime. On exit — normal or not — no task from that group exists.
Multiple simultaneous failures raise an ExceptionGroup, which
is a real exception carrying all of them rather than the first:
except* matches by type inside the group and can run more
than one clause, because more than one kind may have occurred.
Ordinary except ExceptionGroup still works when you want the
whole thing.
Cancelling a task does not stop it immediately. It arranges for
CancelledError to be raised at its next await:
Two things follow.
Cleanup still runs
Because it arrives as an exception, try/finally and
async with unwind exactly as they would for any other
failure. That is the whole reason cancellation is safe.
A stretch with no await cannot be cancelled
Cancellation is cooperative. A CPU loop with no suspension point in it ignores the request until it next suspends — which may be after the work you were cancelling has finished.
CancelledError inherits from BaseException, not Exception
— deliberately, so that except Exception does not swallow it.
This makes the broad-catch anti-pattern actively dangerous in
async code:
That happens to be correct. But the version people write with
except BaseException, or a bare except:, catches
cancellation and ignores it — producing a task that cannot be
stopped, and a shutdown that hangs.
asyncio.timeout cancels whatever is inside when the deadline
passes and raises TimeoutError at the boundary. It composes:
an inner timeout can be shorter, and the outer one still applies
to the total.
wait_for is the older form for a single awaitable.
The important property is that this is real cancellation rather
than abandonment — the work is stopped and its cleanup runs.
Compare with a thread, where there is no equivalent: a
concurrent.futures timeout stops waiting for the result and
the thread carries on working. Async cancellation actually
propagates.
The subtle case. Your finally block runs during cancellation —
and if it awaits something, that await can be cancelled too:
During a normal cancellation this is fine, because a task is cancelled once. But a second cancellation — a shutdown while already cancelling — can interrupt the release, and the connection leaks.
asyncio.shield protects a critical await from cancellation:
Use it sparingly and only for genuinely short cleanup. Shielding something slow means a cancellation that does not take effect, which is the problem you were avoiding.
The better answer where it is available is a context manager that handles this once:
Signals set an Event; the long-running tasks watch it and
return cleanly; the group waits for all of them; final work
happens after. Nothing is killed mid-operation, because nothing
needed to be.
That structure — a signal setting a flag, tasks that check it, one group owning their lifetime — is worth copying wholesale. It is the difference between a service that stops and one that has to be killed.
Every task now has an owner, failures reach someone, and cancellation stops work rather than abandoning it. The rule worth carrying out of this lesson is the smallest one: never create a task nobody waits for. Almost every mysterious async bug traces back to a coroutine with no owner.
Next is Async I/O in Anger, which applies all of this to real systems — connection pools, backpressure when producers outrun consumers, and the boundary where async code has to call synchronous code without deadlocking.
Before you move on, take an asyncio.gather in your code and
convert it to a TaskGroup. Then make one task raise and
observe the difference: with gather, the others keep running
after your function has returned; with a group, they are
cancelled and awaited before the exception reaches you.
OWNERSHIP
a bare create_task with nobody waiting:
may be garbage collected before it runs
its exception is never retrieved
nothing cancels it at shutdown
every task needs an owner with a lifetime
TASK GROUPS
async with asyncio.TaskGroup() as group:
group.create_task(coro)
on exit: nothing from the group is still running
any failure cancels the siblings, then raises
gather: re-raises the FIRST error and leaves the rest running
gather(return_exceptions=True): turns failures into values
- unread results are unnoticed errors
ExceptionGroup carries several at once
except* FileNotFoundError as group: matches inside the group,
and more than one clause can run
CANCELLATION
task.cancel() raises CancelledError at the next AWAIT
cooperative: a CPU stretch with no await ignores it
try/finally and async with cleanup DO run
CancelledError inherits BaseException, not Exception
bare except / except BaseException swallows it -> unstoppable
if you catch it, ALWAYS re-raise
TIMEOUTS
async with asyncio.timeout(30): composes; nesting works
await asyncio.wait_for(coro, 10) single awaitable
real cancellation, unlike a thread, whose work continues
CLEANUP
an await inside finally can itself be cancelled
asyncio.shield(short_critical_cleanup()) sparingly
better: an async context manager that owns the resource
SHUTDOWN
signal handler -> Event.set()
tasks watch the Event and return
a TaskGroup owns their lifetime; final work after the block
await asyncio.sleep(0) to give cancellation a landing pointtry:
await process_all(paths)
except* FileNotFoundError as group:
for error in group.exceptions:
log.warning("missing: %s", error)
except* PermissionError as group:
...task.cancel()async def download(url, path):
handle = await open_file(path)
try:
async for chunk in stream(url):
await handle.write(chunk)
finally:
await handle.close() # runs on cancellation tootry:
await work()
except Exception:
log.warning("failed, continuing") # cancellation gets throughtry:
await work()
except asyncio.CancelledError:
await record_partial_progress()
raise # never swallow itasync with asyncio.timeout(30):
await fetch_everything()result = await asyncio.wait_for(fetch(url), timeout=10)async def process(photo):
connection = await pool.acquire()
try:
await do_work(connection, photo)
finally:
await connection.release() # may itself be cancelled finally:
await asyncio.shield(connection.release()) async with pool.acquire() as connection:
await do_work(connection, photo)async def main():
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for signal_name in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(signal_name, stop.set)
async with asyncio.TaskGroup() as group:
group.create_task(serve(stop))
group.create_task(process_queue(stop))
await flush_pending()