Cancellation and structured concurrency
Design task lifetimes that survive cancellation races, join child work, and release resources only after operations become terminal.
On this page
The question for today
What you’ll understand
- Distinguish requesting cancellation from observing terminal completion.
- Use a structured task scope to account for children and cleanup.
- Calculate shutdown latency from a concrete cooperative cancellation trace.
A client disconnects while your service is fetching three upstream records. Returning an error to the client does not answer what happens to those fetches, their sockets, or their buffers. Cancellation is a request to stop pursuing a result; resource reclamation needs evidence that the relevant activity has stopped using its resources. Structured concurrency makes that evidence easier to collect by giving related tasks a shared lifetime. The difficult cases are races: success arrives alongside a timeout, cleanup suspends, or an operation has crossed a boundary where interruption is impossible.
A cancellation request is an event, not a completed shutdown
A useful task state machine separates running, cancellation requested, cleaning up, and terminal. A request can race with normal success, and the terminal outcome may therefore still be success. Decide how the application resolves that race and record one authoritative outcome. Avoid treating a Boolean cancellation flag as proof that a socket or memory region is unused. An operating system request may continue after the coroutine that initiated it no longer wants its answer; a remote service can also commit a side effect before it sees your disconnect.
In Python asyncio, cancellation injects CancelledError at a cooperative opportunity. Cleanup belongs in a finally block; when cancellation is caught explicitly, it generally needs propagation after cleanup. These semantics explain why a CPU loop that never yields can delay cancellation. They do not promise to stop arbitrary blocking work or reverse an external transaction. When wrapping another execution mechanism, document its separate stop and join protocol instead of assuming cancellation crosses every boundary.
Make child lifetimes belong to a scope
A structured task group owns the children it starts and waits for them before its scope exits. Python TaskGroup also cancels remaining children when a child fails with an ordinary non-cancellation exception, then collects failures after waiting. This gives callers a place to observe errors and reason about the completion of owned work. An intentionally detached background service needs a different explicit owner, shutdown path, and failure observer; simply dropping its handle creates none of those guarantees.
Treat cleanup ordering as a dependency problem. Stop admitting new work before draining children; keep shared resources alive until the last dependent child is terminal; close those resources afterward. Independent child cleanups can overlap, so their durations need not add. However, closing a shared connection before a child finishes using it can create a second failure that obscures the first. Shielding selected cleanup from cancellation can be appropriate, but the shielded activity still needs a retained handle and someone responsible for joining it.
Budgets, tests, and the limits of a shutdown analogy
A request deadline and a resource-release deadline are different measurements. A user-facing timeout can expire while cleanup continues, so reserve time and capacity for that tail. Make repeated shutdown requests idempotent: the second request should not double-close a resource or discard the only completion observer. Exercise deterministic schedules in tests: completion before cancel, cancel before completion, failure during cleanup, and simultaneous sibling failures. Assert both the reported outcome and that all owned work and resource references have been accounted for.
Use the queue lab as a narrow drain analogy. Its arrival-rate and service-rate controls move a fluid backlog initially equal to four. Press Reset, set arrival rate to zero and service rate to four units per second, predict the drain time, then press Play and pause at the empty point. For a separate comparison, press Reset again, explicitly set both arrival and service rates to four, predict whether the new run drains, then press Play. This illustrates why shutdown first closes admission; it is not an execution trace of TaskGroup, does not model cancellation delivery, and cannot prove that your actual children released their sockets.
Work it through
Read a cancellation timeline
At time 100 ms a parent requests cancellation of three children. They observe it at 101, 104, and 102 ms, then need 2, 7, and 4 ms of independent cleanup. There is no extra parent cleanup. When can the scope exit?
Add observation time and cleanup duration for each child: the terminal timestamps are 103 ms, 111 ms, and 106 ms. Cancellation delivery is not instantaneous in this trace.
Take the latest terminal timestamp, 111 ms. The first child finishing at 103 ms cannot release shared state still needed by the second child.
Subtract the request timestamp: shutdown takes 11 ms after the cancellation request. A response budget ending at 100 ms does not make that resource-release interval disappear.
The scope can exit at 111 ms, provided every child really is terminal and no other owner remains. The longest cleanup duration alone, 7 ms, understates this trace because observation was delayed.
The living notebook
Explore the model
Interactive lab · queue
Close admission before draining
Control arrival rate λ and service rate μ for the fluid backlog max(0, 4 + (λ − μ)t). Press Reset, set λ = 0 and μ = 4, predict the drain time, then press Play and pause to inspect it.
- Fluid backlog
- 4
- Net rate · λ − μ
- −1 /s
- M/M/1 mean residence
- 1 s
Fluid backlog starts at 4 and follows max(0, 4 + (λ − μ)t). The separate M/M/1 residence-time formula assumes Poisson arrivals, exponential service and λ < μ. Parameter changes pause playback and recompute the model at the current elapsed time. Reset restores both parameters and returns time to zero. Playback starts paused. Adjust either parameter, then choose Play or Step.
Look forZero arrivals lets existing work drain in this model. An empty fluid queue is not evidence that an actual cancellation protocol has joined every task.
Pause & reason
Check your understanding
Your turn
Try it yourself
Two children observe cancellation at 8 ms and 13 ms. Their cleanups take 6 ms and 2 ms. Parent cleanup takes another 3 ms after both children are terminal. Determine scope exit, and explain whether a 10 ms response deadline proves timely resource release.
Take a moment to make a prediction or work it out on paper. Open a hint when you need one.
Give me a hint
First find both terminal timestamps, then add parent cleanup after the later one.
Compare with the solution
The children become terminal at 14 ms and 15 ms. Parent cleanup runs afterward, so exit is at 18 ms. A response deadline at 10 ms says when the caller stops waiting for its result; it does not prove that child or parent resources were free by then. Instrument both intervals and define who owns any cleanup that outlives the response.
Keep the idea
Take it with you
- Cancellation requests do not establish terminal completion or undo remote side effects.
- Structured scopes join owned children and provide a place to observe their failures.
- Shutdown correctness needs admission control, explicit ownership, and tests of competing event orders.
Read further
Original sources and deeper explanations to follow at your own pace.
Optional progress, saved in this browser when available. Revisit any lesson at any time.
Lesson not marked complete.