Cancellation and TaskGroup
The quality of async code is shaped less by how much it can run concurrently and more by how well it handles cancellation and cleanup. `CancelledError` is part of control flow, and `TaskGroup` gives that control flow a structured home.
Quick takeaway: cancellation is modeled through exception propagation. If cleanup is required, perform it in `finally` or under `except asyncio.CancelledError`, then usually re-raise. For groups of tasks, `TaskGroup` is the safest default.
Picture the Structured-Concurrency Flow
Code Pattern for Proper Cancellation
import asyncio
async def worker(name: str) -> None:
try:
while True:
print(f"{name}: tick")
await asyncio.sleep(0.2)
except asyncio.CancelledError:
print(f"{name}: cleanup")
raise
async def main() -> None:
try:
async with asyncio.timeout(0.6):
async with asyncio.TaskGroup() as task_group:
task_group.create_task(worker("alpha"))
task_group.create_task(worker("beta"))
except TimeoutError:
print("timeout reached")
asyncio.run(main())`asyncio.timeout()` surfaces a `TimeoutError` outside the block, but internally it cancels the running work. Each worker must clean up and re-raise `CancelledError` so structured cancellation continues to behave correctly.
Why Swallowing CancelledError Is Dangerous
- the parent loses the cancellation signal
- graceful shutdown sequencing becomes unreliable
- timeout behavior can turn into hanging behavior
What TaskGroup Gives You
- task lifetime tied to a lexical block
- sibling cancellation on failure
- grouped exceptions through
ExceptionGroup
Checklist
Cleanup in finally
Files, sockets, semaphores, and consumers should usually clean up in `finally` blocks.
Re-raise cancellation
Unless you have a very specific reason, do not swallow `CancelledError`.
Prefer TaskGroup
For new code, `TaskGroup` is usually safer than scattered `create_task()` calls.
Design for timeout
Timeout paths are part of the contract, especially in fan-out service calls.