Rust Tokio Cancellation
Cancellation is stopping async work before it finishes. Dropping a future cancels it.
One flag, many holders
Every clone of a token watches the same flag — cancel once, and all of them hear it:
let token = CancellationToken::new();
let worker_token = token.clone();Watch the token in select!
The standard shape: your work races the token, and the token branch tidies up:
tokio::select! {
_ = token.cancelled() => println!("stop signal received"),
_ = sleep(Duration::from_secs(60)) => println!("work finished"),
}Child tokens stop one branch, not the tree
A child hears its parent, never the other way round:
let parent = CancellationToken::new();
let child = parent.child_token();
child.cancel();
println!("child cancelled = {}", child.is_cancelled());
println!("parent cancelled = {}", parent.is_cancelled());
let child2 = parent.child_token();
parent.cancel();
println!("second child cancelled = {}", child2.is_cancelled());Run a future under a token
run_until_cancelled folds the select! shape into one call — Some for finished, None for stopped:
let done = token.run_until_cancelled(async { 7 }).await;
println!("finished = {done:?}");
token2.cancel();
let stopped = token2.run_until_cancelled(sleep(Duration::from_secs(60))).await;
println!("stopped = {stopped:?}");A guard cancels when it drops
drop_guard ties the token to a value's life. It takes the token itself, so keep a clone for watching:
let token = CancellationToken::new();
let watching = token.clone();
let guard = token.drop_guard();
drop(guard);
println!("cancelled = {}", watching.is_cancelled());Example
// Cargo.toml: tokio = { version = "1", features = ["full"] }, tokio-util = "0.7"
use tokio::time::{sleep, Duration};
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() {
let token = CancellationToken::new();
let worker_token = token.clone();
let worker = tokio::spawn(async move {
loop {
tokio::select! {
_ = worker_token.cancelled() => {
println!("worker: stop signal received, tidying up");
break;
}
_ = sleep(Duration::from_millis(20)) => {
println!("worker: tick");
}
}
}
});
sleep(Duration::from_millis(55)).await;
token.cancel();
worker.await.unwrap();
println!("shutdown complete");
}Key points
- Dropping a future cancels it at its next .await point — there is no kill switch, and none is needed
- JoinHandle::abort() asks a spawned task to stop.
- Await the handle to learn that it was cancelled.
- tokio::signal::ctrl_c().await waits for the user to press Ctrl+C — the classic shutdown trigger
- Graceful shutdown means: stop taking new work, give current work a short deadline to finish, then exit
- The Tokio tutorial's servers use a different pair of channels for the same job.
- One is a broadcast channel, used as the stop signal.
- The other is an mpsc channel.
- Its closing proves every task has ended.
- Cancellation is co-operative.
In this example, a worker loops with select!: it either sleeps 20 milliseconds and prints a tick, or it hears the cancellation token. After about 55 milliseconds, main cancels the token. The worker's next round takes the cancelled branch, prints its tidy-up line, and breaks the loop. Main awaits the handle, so the shutdown is fully finished before the program exits — polite, complete, and controlled.
Note
A task that never awaits — a tight loop — cannot be cancelled until it reaches an .await.
Try it Yourself
Edit the code, then press Run.
Running is simulated · Rust Playground
Exercise
How do you stop one spawned task from another part of the program?