rust 110 lines · 3 tabs

Graceful Task Shutdown in Tokio Using CancellationToken

Shared by codesnips Jul 2026
3 tabs
use std::time::Duration;
use tokio_util::sync::CancellationToken;

pub struct Worker {
    id: usize,
    token: CancellationToken,
}

impl Worker {
    pub fn new(id: usize, token: CancellationToken) -> Self {
        Worker { id, token }
    }

    pub async fn run(self) {
        loop {
            tokio::select! {
                biased;

                _ = self.token.cancelled() => {
                    tracing::info!(worker = self.id, "cancellation observed, stopping");
                    break;
                }

                result = self.process() => {
                    if let Err(err) = result {
                        tracing::warn!(worker = self.id, %err, "work item failed");
                    }
                }
            }
        }

        self.drain().await;
    }

    async fn process(&self) -> Result<(), std::io::Error> {
        tokio::time::sleep(Duration::from_millis(250)).await;
        Ok(())
    }

    async fn drain(&self) {
        tracing::debug!(worker = self.id, "flushing in-flight state before exit");
        tokio::time::sleep(Duration::from_millis(20)).await;
    }
}
3 files · rust Explain with highlit

This snippet demonstrates cooperative task cancellation in an async Rust service built on Tokio, using tokio_util::sync::CancellationToken to fan out a single shutdown signal to many long-running tasks. The core problem is that dropping a JoinHandle does not cancel the future it drives cleanly — an in-flight database write or a partially sent frame may be aborted at an arbitrary await point. A CancellationToken instead makes cancellation an explicit, observable event that each task selects on, so tasks decide where it is safe to stop.

In worker.rs, the Worker holds a token and its run loop uses tokio::select! with the biased branch calling token.cancelled().await. Biasing means the cancellation branch is polled first on every loop iteration, guaranteeing the loop exits promptly rather than starting yet another unit of work once shutdown has begun. The process step is itself awaited inside the same select!, so a shutdown that arrives mid-task interrupts the future at its next await point without leaving the loop in an inconsistent state.

The key structural idea appears in supervisor.rs. A single parent token is created, and each worker receives parent.child_token(). Child tokens are cancelled when the parent is cancelled, but can also be cancelled independently, which is how the supervisor kills one misbehaving worker without touching the others. spawn_workers collects every JoinHandle so the shutdown path can join_all them and observe clean completion rather than firing cancellation and walking away.

The run_until_shutdown method wires an OS signal to the token: when Ctrl-C arrives it calls token.cancel(), which wakes every cancelled() future across all workers simultaneously. It then awaits the join set with a timeout so a stuck task cannot hang the process forever — a common pitfall where cooperative cancellation degrades into an unbounded wait.

The main.rs tab shows the assembly: build a Supervisor, register work, and hand control to the runtime. The trade-off of this pattern is that cancellation is cooperative — a task that never reaches an await point or ignores its token will not stop — but in exchange it yields predictable, leak-free shutdown and fine-grained control that abrupt task aborts cannot provide.


Related snips

Share this code

Here's the card — post it anywhere.

Graceful Task Shutdown in Tokio Using CancellationToken — share card
Link copied