The Rust Engineering Handbook / Chapter 61
Tasks, Cancellation, Structured Concurrency, and Shutdown
Attach every async task to an owned lifetime, propagate cancellation deliberately, and make graceful shutdown a finite join protocol.
The process reported a clean exit. The retry loop reported something else: one final write, sent after the storage client had begun closing. The write failed, its error went to a logging subscriber that no longer existed, and nobody observed the task’s return value. Every local operation had behaved as designed. The lifetime graph had not.
The missing edge was created by an innocent line:
tokio::spawn(retry_pending(batch));
The call returned a join handle, but the caller dropped it. On Tokio, dropping a JoinHandle detaches the task; it does not cancel it. The runtime still owned enough machinery to poll the future, yet the application had discarded its permission to observe completion. “Background” had become an unbounded lifetime category.
Task creation is therefore an ownership decision. Every spawned task needs an answer to three questions: which system lifetime contains it, how does that owner request it to stop, and how does the owner prove that it stopped? A cancellation signal answers only the middle question. A graceful shutdown protocol answers all three.
A task is not owned merely because it was spawned
spawn transfers an owned future into an executor and returns an observation capability. The future can run independently of the stack frame that launched it, which is why ordinary spawning commonly requires owned, Send, and 'static state. Chapter 59 established what those bounds say about stored state. They say nothing about application supervision.
A join handle gives the caller a completion edge:
- awaiting it yields the task’s output or a task-level failure such as panic or cancellation;
- retaining it makes shutdown able to wait for destruction of the task’s locals;
- dropping it may detach the task, depending on the task API;
- aborting through it requests runtime cancellation but is not itself proof of completion.
Thread terminology can mislead here. A task does not own a thread, and a parent future that calls spawn does not automatically become the spawned task’s lifetime parent. If the parent returns, the spawned child can continue. The nesting visible in source code is not necessarily a task tree.
The application must create that tree with retained handles, task sets, scopes, or a supervisor abstraction. The exact library is secondary. The invariant is durable:
No task may outlive the smallest system component that owns its effects, unless the longer lifetime is explicit and independently supervised.
Fire-and-forget can be legitimate for a best-effort metric or bounded cache hint, but the name must not conceal effects that require delivery, ordering, rollback, resource release, or error observation. Even best-effort work needs bounded admission; otherwise “unimportant” tasks can exhaust the important system.

Build the tree from effects, not call syntax
For relay-service, a useful ownership tree is:
process
└── relay supervisor
├── signal listener
├── connection intake
├── worker set
│ ├── worker 0
│ ├── worker 1
│ └── worker N
├── commit coordinator
└── telemetry exporter
The tree is an effects map. Intake owns socket acceptance. Workers own accepted requests until they produce a result or an explicit terminal disposition. The commit coordinator owns durable-write ordering. Telemetry may have a bounded flush obligation. The supervisor owns the policy that coordinates them.
Not every node receives the same stop instruction at the same instant. Cancellation propagation is directional, but shutdown ordering follows resource dependencies. Intake should stop before workers drain, or new work can race with the drain. Workers should stop producing commits before the commit coordinator declares its queue empty. Telemetry should remain alive long enough to record shutdown, but not indefinitely delay exit.
A flat vector of join handles can prove termination, yet a tree carries more policy. A child cancellation token can receive cancellation from its parent without allowing an individual child to cancel the whole service. A per-connection subtree can be cancelled when its client disconnects while peer connections continue. A storage-subsystem failure can either cancel only dependent work or escalate to the process root, according to a written failure policy.
Structured concurrency is this lifetime relationship made difficult to forget: child work is created inside a scope or owned task collection, and the enclosing operation cannot finish successfully while children remain unaccounted for. Rust’s standard library does not prescribe one async structured-concurrency facility. Runtime task sets, cancellation tokens, owned supervisors, and scoped task APIs offer different pieces. Review the properties, not the label:
| Property | Question to prove |
|---|---|
| Admission | Can child creation exceed a finite concurrency or memory budget? |
| Parent completion | Must all children finish, be cancelled and joined, or be transferred to another named owner? |
| Error propagation | Which child errors fail the parent, and which are collected as data? |
| Panic propagation | Is a child panic observed, isolated, restarted, or process-fatal? |
| Cancellation direction | Can a child stop siblings or ancestors? Is that escalation deliberate? |
| Cleanup | Does the parent await termination, not merely send a signal? |
Cancellation is a request with a commit boundary
Cancellation can arrive while a future is suspended at any .await selected by the runtime or by combinator logic. Dropping that future destroys its stored locals, but it does not reverse effects already performed. A socket write may be partial. A database may have committed. An item removed from an input queue may no longer be available for another consumer. Chapter 49’s cancellation-safety contract therefore applies inside every task.
Classify each operation around a commit boundary:
- Before commit, cancellation may discard local preparation.
- At commit, the operation records enough durable identity to determine whether the effect happened.
- After commit, cancellation changes the response path, not historical reality; recovery may need idempotency, reconciliation, or compensation.
A cancellation token is cooperative. The task observes it at a chosen point and can preserve invariants before returning. This is appropriate for drainable workers, loops with explicit ownership, and cleanup that must await another operation. A token clone usually represents the same cancellation domain; a child token represents downward propagation without upward cancellation.
Drop-based cancellation connects lexical ownership to a request: when a guard or owner is dropped, it signals the token. That is useful for preventing forgotten signal paths, but Drop cannot await async cleanup. The owner must still retain something joinable and perform the join before its async scope ends.
Abort is sharper. In Tokio, abort schedules an async task for cancellation at a yield point, then returns before the task is necessarily gone. Awaiting the handle is what observes termination. Destructors for the future’s stored locals run when cancellation completes, but async compensating work does not magically run. A task that does not yield may finish before the abort takes effect. Work already running through spawn_blocking generally cannot be aborted; it needs its own cooperative stop mechanism, finite operation, or process-level containment.
These mechanisms are not interchangeable:
| Mechanism | Strength | Principal hazard |
|---|---|---|
| Close input / drop senders | Lets consumers drain naturally and finish | A forgotten sender clone keeps the stream open |
| Cooperative token | Gives code an invariant-preserving stop point | Code may fail to observe it or may observe too late |
| Drop a cancellation guard | Connects owner destruction to signalling | Signal still does not join; Drop cannot await |
| Abort async task | Forces cancellation at runtime yield boundaries | External effects remain; blocking work may continue |
| End runtime/process | Contains all remaining work | Coarse failure boundary; graceful obligations may be lost |
An executable shutdown trace
The companion task-pipeline-lab uses a Tokio bounded channel and a CancellationToken. Its supervisor retains the worker handle and records the shutdown sequence:
let mut phases = vec![ShutdownPhase::StopIntake];
drop(tx);
phases.push(ShutdownPhase::CancelChildren);
token.cancel();
phases.push(ShutdownPhase::DrainInFlight);
phases.push(ShutdownPhase::JoinTasks);
let completed = worker.await.expect("worker panic is supervisor failure");
phases.push(ShutdownPhase::CloseResources);
The worker responds to cancellation by closing its receive side against further admission, then draining already accepted messages. The test sends nine jobs through a capacity-two channel, requests shutdown, awaits the worker, and proves accepted == completed. Another test deliberately panics a child and proves that awaiting the handle exposes a panic-bearing join error.
This is a teaching-sized protocol, not a universal implementation. A production supervisor must distinguish accepted, started, committed, acknowledged, retriable, and abandoned work. Still, the trace demonstrates the critical separation:
- dropping the final producer stops future intake;
- cancelling tells children that system intent changed;
- draining accounts for work already owned;
- joining proves task termination and surfaces failure;
- resource closure happens only after users of those resources are gone.
The fixture is in examples/rust-engineering-handbook/part-10/task-pipeline-lab/. Its current-thread runtime choice makes the trace deterministic enough to inspect; it does not claim that production scheduling or cancellation selection is deterministic.
Five phases of graceful shutdown
Stop admission
One component should own the operating-system signal or administrative shutdown request. Multiple tasks independently listening and exiting create races in which no one coordinates the whole graph. The root converts the external event into internal policy.
First stop accepting new sockets, requests, jobs, retries, and periodic work. Mark readiness false before or with this transition so upstream systems stop routing new work. Close bounded senders or receivers according to the channel contract, and inventory every cloned sender that could keep admission alive.
Signal cancellation
Propagate cancellation down the task tree. Tasks should know which work may be abandoned immediately and which must cross an invariant-preserving boundary. A parser can often discard an incomplete frame. A commit coordinator may need to finish or reconcile a durable transaction. A telemetry exporter can receive a later, shorter flush window.
Cancellation tokens carry intent, not authority to violate ownership. A worker must not drop a request after removing it from a queue unless the protocol defines requeue, negative acknowledgment, idempotent retry, or explicit loss.
Drain owned work
Draining needs a finite definition. “Wait until idle” is not finite if a peer can hang forever or a producer remains open. Define drain completion in observable state: no admitted requests, no in-flight commits, no outstanding response permits, and no child tasks outside the supervisor’s set.
Resource order matters. Do not close the storage client while commit tasks still borrow or clone it. Do not stop telemetry before the final task outcomes are recorded. Conversely, do not let telemetry flush block forever on a failed network dependency.
Join and escalate at a deadline
A graceful deadline is an absolute budget for the whole sequence, not a fresh full timeout for each phase. Record how much time stop-admission and draining consumed, then pass the remaining deadline downward. Otherwise five “ten-second” phases quietly create a fifty-second shutdown.
When the deadline expires, escalate by policy: abort remaining async tasks, stop awaiting noncritical flushes, close connections, or allow the service manager to terminate the process. Await aborted handles long enough to observe destruction when the runtime permits it. Blocking tasks need a separate bound; a runtime cannot preempt arbitrary synchronous code safely.
Close resources and report residue
Only after joins should the root close shared clients, runtime adapters, temporary files, and telemetry. Emit a structured shutdown outcome: cause, duration by phase, accepted/completed/abandoned counts, tasks aborted, panics observed, commits unresolved, and resources that exceeded their deadline. A zero process status should mean the chosen durability and safety obligations were met, not merely that main returned.
Panics and child failure need policy before the incident
Awaiting a join handle usually separates two error layers: the task may have returned Result<T, E>, while the join itself may report cancellation or panic. Flattening those layers into one string loses the difference between a handled domain failure and a broken task invariant.
Possible supervisor policies include:
- fail fast: a critical coordinator panic cancels the root and begins shutdown;
- isolate: one connection task fails, records its request outcome, and leaves peers running;
- restart: a stateless periodic worker restarts under a bounded retry budget;
- aggregate: a bounded parallel operation waits for all children and returns every item error;
- quorum: the parent continues if a declared minimum of replicas or shards remains healthy.
Restart without ownership and rate limits turns a panic into a task storm. Isolation without error observation turns data loss into apparent success. Fail-fast without drain analysis can corrupt application-level protocols. The correct policy follows the effect the child owns.
Exercise: recover the lost task tree
Audit a service whose start method spawns a listener, one task per connection, a retry loop, and a telemetry exporter, then drops every handle. It closes the storage client as soon as a termination signal arrives.
Produce four artifacts:
- Draw the task tree. For every node, name its effects, parent, admission bound, cancellation input, completion output, panic policy, and join owner.
- Write a phase trace for
stop intake → cancel → drain → join → close. Include channel closure and all cloned senders. Mark the commit boundary for an accepted relay message. - Allocate one absolute shutdown deadline across phases. Define the evidence that advances each phase and the escalation when its sub-budget is exhausted. Include blocking work explicitly.
- Inject three failures: a worker panic, a commit that never completes, and a telemetry flush failure. Assert the final process outcome and residue report for each.
Reject a redesign that merely stores handles in a vector but never awaits them, or that aborts everything immediately and calls the result graceful. A strong design makes every longer-lived task visible, bounds child creation, and tests both cooperative and forced paths.
Task-lifetime review
- Every task belongs to a named system lifetime and has a retained join path.
- Dropping a handle is treated according to the specific API, never assumed to cancel.
- Cancellation is separated from completion and from reversal of external effects.
- Child tokens and task groups reflect intentional propagation direction.
- Intake stops before draining; resources close after their users join.
- The shutdown deadline is shared end to end and has an escalation policy.
- Blocking operations have finite or cooperative termination outside async abort.
- Domain errors, cancellation, and panics remain distinguishable.
- Detached best-effort work is bounded and truly allowed to be lost.
- Shutdown telemetry reports residue, not just elapsed time.
Once every task has an owner, the next limit becomes visible: each edge in the tree can still accumulate messages, bytes, permits, and waiting futures. Lifetime ownership prevents orphaned work. It does not by itself prevent an owned service from exhausting memory. That requires a bounded flow graph.
Sources and version note
The language-level task substrate remains the Future and wake contract described in Chapters 58–60. Tokio supplies the runtime-specific examples: JoinHandle documents detachment on drop, panic/cancellation observation, abort, and completion; the task module documents cancellation at yield points and the special limitation of blocking tasks; JoinSet is an owned task-collection example. Tokio Utilities’ CancellationToken documents cloned and child-token propagation. These are current third-party runtime contracts, not guarantees of Rust syntax or every executor.
The companion fixture records its exact resolved crate versions in Cargo.lock and declares Rust 1.85 as its MSRV. It was verified separately with Rust 1.97.0 and 1.85.0; the independent editorial routine must revalidate current contracts before acceptance.
Continue reading
Full table of contents