PYTHON / ADVANCED PYTHON
async patterns: gather, timeouts, and cancellation
Run coroutines concurrently with gather, bound them with wait_for or asyncio.timeout, and handle CancelledError so cleanup still runs.
What you will learn
- Use asyncio.gather to overlap awaits; results come back in argument order
- Know that gather's first exception propagates but leaves sibling tasks running
- Bound slow awaits with wait_for or asyncio.timeout, which cancel at the deadline
- Release resources in finally and re-raise CancelledError instead of swallowing it
Understanding async patterns: gather, timeouts, and cancellation
An event loop runs exactly one coroutine step at a time; concurrency comes from the points where a coroutine says await and hands control back. Writing `for url in urls: await fetch(url)` therefore takes the sum of all the delays, because the second fetch has not even been created when the first suspends. `asyncio.gather` fixes that by wrapping every argument in a Task immediately, so the loop has several suspended coroutines to interleave, and the whole call takes roughly as long as the slowest one. The list it returns is ordered by argument position, not by which coroutine finished first, so you never need to tag results to match them up.
The part people get wrong is gather's failure policy. By default the first exception is re-raised out of the await as soon as it happens, but the sibling Tasks are not touched: they stay scheduled and keep running in the background, and if one of them later fails you get a stray "Task exception was never retrieved" message. Passing `return_exceptions=True` changes only the reporting, not the lifetime: exceptions arrive as ordinary values in the result list so you can inspect every slot. If you actually want "one failure kills the group", that is `asyncio.TaskGroup` (3.11+) or an explicit `task.cancel()` in a finally block.
A timeout is nothing more than a scheduled cancellation, and a cancellation is a `CancelledError` thrown into the coroutine at whatever await it is currently parked on. That makes cancellation cooperative rather than preemptive: a coroutine stuck in a tight CPU loop with no await cannot be interrupted, and a coroutine that catches `CancelledError` and returns normally silently defeats the deadline. Since Python 3.8 `CancelledError` inherits from `BaseException`, so `except Exception` will not swallow it by accident, but that also means cleanup belongs in `finally`. If you must react to cancellation, log and then `raise` again so the cancellation keeps travelling outward.
import asyncio
async def work(name, delay):
print(f"start {name}")
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
print(f"cancelled {name}")
raise
print(f"done {name}")
return name.upper()
async def main():
results = await asyncio.gather(work("a", 0.1), work("b", 0.2))
print("gather ->", results)
try:
await asyncio.wait_for(work("slow", 1.0), timeout=0.2)
except asyncio.TimeoutError:
print("wait_for gave up")
asyncio.run(main())A timeout is a scheduled cancellation, and a cancellation is a CancelledError injected at the coroutine's current await point.
Worked examples
gather does not cancel its siblings
Shows that a failing coroutine ends the gather immediately while the other task keeps running, and how return_exceptions=True collects results instead.
import asyncio
async def ok(delay):
await asyncio.sleep(delay)
print(f"ok {delay} finished")
return delay
async def boom():
await asyncio.sleep(0.1)
raise ValueError("boom")
async def main():
try:
await asyncio.gather(ok(0.3), boom())
except ValueError as exc:
print("caught:", exc)
print("gather is over, ok(0.3) is not")
await asyncio.sleep(0.4)
results = await asyncio.gather(ok(0.05), boom(), return_exceptions=True)
print("with return_exceptions:", results)
asyncio.run(main())Example explained
Line 1The ValueError surfaces at 0.1s, so the await on gather ends long before ok(0.3) is done.
Line 2"ok 0.3 finished" printing after the except block proves the sibling Task was never cancelled.
Line 3With return_exceptions=True nothing is raised; slot 1 of the list holds the exception object itself.
Line 4Result order still matches argument order even though ok(0.05) finished before boom() failed.
CancelledError is not an Exception
Demonstrates that except Exception cannot intercept a cancellation, so finally is the only reliable place to clean up.
import asyncio
async def naive():
try:
await asyncio.sleep(10)
except Exception:
print("this never runs")
finally:
print("finally always runs")
async def main():
task = asyncio.create_task(naive())
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("task confirmed cancelled")
print("task.cancelled() ->", task.cancelled())
asyncio.run(main())Example explained
Line 1task.cancel() only requests cancellation; the error is delivered when the loop next resumes the sleep.
Line 2except Exception is skipped because CancelledError subclasses BaseException, not Exception.
Line 3Awaiting the cancelled task re-raises CancelledError in the caller, which is how you confirm shutdown finished.
Line 4task.cancelled() is True only because the error propagated out instead of being swallowed.
One deadline for a whole block
Uses asyncio.timeout (Python 3.11+) to put a single time budget across several sequential awaits rather than one per call.
import asyncio
async def step(name, delay):
await asyncio.sleep(delay)
print("finished", name)
async def main():
try:
async with asyncio.timeout(0.25):
await step("first", 0.1)
await step("second", 0.1)
await step("third", 0.5)
except TimeoutError:
print("budget of 0.25s exhausted")
asyncio.run(main())Example explained
Line 1asyncio.timeout schedules a cancel on the current task at 0.25s, so the budget covers all three awaits together.
Line 2The first two steps fit inside the budget and print normally.
Line 3The third is cancelled mid-sleep; __aexit__ converts that CancelledError into TimeoutError so ordinary error handling works.
Line 4wait_for(step(...), 0.25) would instead give each call its own fresh 0.25s.
Important notes
asyncio.timeout and asyncio.TaskGroup were added in Python 3.11; on older versions use asyncio.wait_for, and note that asyncio.TimeoutError is an alias of the builtin TimeoutError from 3.11 on.
Cancellation is only delivered at an await, so a coroutine doing heavy CPU work between awaits ignores timeouts entirely; push that work to a thread or process executor.
Common mistakes
Awaiting each coroutine inside a for loop and calling it concurrent: nothing overlaps, and total time is the sum of the delays instead of the maximum.
Assuming gather cancels the rest when one coroutine raises: the survivors keep running past the await, and a later failure in one of them prints "Task exception was never retrieved".
Catching CancelledError to log it and then returning normally: the task looks successful, wait_for/asyncio.timeout stop raising TimeoutError, and shutdown can hang forever.
Try it yourself
Change, predict, then run
Write fast() that sleeps 0.1s and returns "fast" and flaky() that raises RuntimeError after 0.05s, then call asyncio.gather on both twice, once with the default and once with return_exceptions=True, printing what each attempt yields. Finish by wrapping fast() in asyncio.wait_for with a 0.01s timeout and printing the type of the exception you get.
Open the Python workspaceCheck your understanding
You run `await asyncio.gather(slow(), broken())` with default arguments, where broken() raises after 0.1s and slow() sleeps for 2s. What happens to slow()?
- gather raises at 0.1s and slow() keeps running as an independent Task in the background
- slow() is cancelled the instant broken() raises, so gather returns after 0.1s with nothing pending
- gather waits the full 2s for slow() to finish and only then re-raises broken()'s exception
- slow()'s result is returned as a one-element list and broken()'s exception is discarded
Show answer
gather propagates the first exception immediately but never touches its other children, so slow() stays scheduled and finishes later (or leaks). Option 2 describes asyncio.TaskGroup, which does cancel siblings on failure; plain gather leaves that cleanup to you.