The background task that won't die
You're running a background worker that processes images. It's halfway through resizing a batch when you hit Ctrl+C to stop the program. The process dies instantly. The resized images are gone. The database transaction is left open. The user gets an error. You need the task to finish its current step, clean up, and then stop.
Rust's async model treats cancellation as a side effect of memory management. There is no "interrupt" signal that pauses a thread and checks a flag. Instead, cancelling a task means dropping the future that represents it. When the future is dropped, the runtime stops polling it. Any resources held by the future are released through Drop implementations. Graceful shutdown relies on this mechanism. You either wait for the task to complete naturally, or you drop the future to trigger cleanup.
Waiting for a task to finish
When you spawn a task, Tokio returns a JoinHandle. This handle is your ticket to the result. It is also a future. Awaiting the handle tells the runtime, "I need the result of this task." The main function pauses until the task returns or panics.
use tokio::task::JoinHandle;
/// Processes a single batch of work.
async fn process_batch() {
// Simulate work that takes time.
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!("Batch done");
}
#[tokio::main]
async fn main() {
// Spawn the task and keep the handle.
// The handle owns the result of the task.
let handle: JoinHandle<()> = tokio::spawn(process_batch());
// Wait for the task to finish.
// This blocks the main function until the task completes.
handle.await.expect("Task panicked");
}
If the task finishes normally, handle.await returns Ok(result). If the task panics, it returns Err(JoinError). The expect call propagates the panic if something goes wrong. This is the simplest form of shutdown. You spawn the work, you wait for it, and you're done.
Awaiting the handle is the handshake. If you skip it, the task vanishes without a trace.
Cancellation is dropping
Dropping the JoinHandle without awaiting it cancels the task immediately. The future is dropped. The runtime stops polling it. Any cleanup in Drop implementations runs, but the future's logic stops at the drop point. This is how Rust implements cancellation. There is no special cancel API. Cancellation is just dropping the future.
This behavior is consistent across the ecosystem. If you pass a future to a function and the function returns early, the future is dropped. The task is cancelled. If you store a future in a struct and the struct is dropped, the task is cancelled. This design keeps the model simple. You don't need to manage cancellation tokens or flags unless you want to. The lifetime of the future controls the lifetime of the task.
You can also cancel a task explicitly using handle.abort(). This marks the task for cancellation. The next time the runtime polls the task, it sees the abort flag and drops the future. This is useful when you need to cancel a task from a different context without dropping the handle immediately.
Dropping the handle is the kill switch. The task stops at the next await point, and cleanup runs immediately.
Racing a shutdown signal
Waiting for a task to finish works when the task has a natural end. Many background tasks run indefinitely. A server loop, a heartbeat sender, a periodic cleanup job. These tasks need a way to stop when the application shuts down.
The standard pattern is to race the task against a shutdown signal. Tokio provides tokio::select! for this. The macro polls multiple futures and returns when the first one completes. If the shutdown signal completes first, the task is dropped. If the task completes first, the signal is dropped.
use tokio::signal;
use tokio::task::JoinHandle;
/// Runs a periodic heartbeat.
async fn heartbeat() {
loop {
// Simulate sending a heartbeat.
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
println!("Heartbeat sent");
}
}
#[tokio::main]
async fn main() {
let handle: JoinHandle<()> = tokio::spawn(heartbeat());
// Race the task against Ctrl+C.
tokio::select! {
// If the task finishes, we get the result.
result = handle => {
println!("Task finished: {:?}", result);
}
// If Ctrl+C is pressed, the signal future completes.
// The task is dropped and cancelled.
_ = signal::ctrl_c() => {
println!("Shutdown signal received. Cancelling task.");
}
}
}
When ctrl_c() completes, select! returns. The handle is dropped because it goes out of scope. The task is cancelled. This is immediate cancellation. The task stops at the next await point. If you need graceful shutdown, the task must be cooperative. It must check for a shutdown signal and finish its current work before stopping.
The race is fair only if both sides yield. If the task blocks, the signal waits forever.
Realistic example: Cooperative worker
Cooperative shutdown requires the task to check a signal regularly. A channel is a common way to send the signal. The main function sends a message when it's time to stop. The task receives the message and breaks out of its loop.
use tokio::signal;
use tokio::task::JoinHandle;
/// Background worker that processes items until told to stop.
async fn worker(mut shutdown_rx: tokio::sync::mpsc::Receiver<()>) {
loop {
tokio::select! {
// Bias towards the shutdown signal.
// If both branches are ready, the shutdown branch wins.
biased;
// Check for shutdown signal.
_ = shutdown_rx.recv() => {
println!("Worker received shutdown signal. Finishing current work...");
break;
}
// Simulate work that takes time.
_ = tokio::time::sleep(tokio::time::Duration::from_secs(1)) => {
println!("Work unit complete.");
}
}
}
println!("Worker cleaned up and exited.");
}
#[tokio::main]
async fn main() {
// Create a channel for the shutdown signal.
// Capacity of 1 is enough for a single signal.
let (tx, rx) = tokio::sync::mpsc::channel(1);
let handle: JoinHandle<()> = tokio::spawn(worker(rx));
// Wait for shutdown signal.
let _ = signal::ctrl_c().await;
println!("Main received Ctrl+C. Signaling worker...");
// Send shutdown signal.
// The worker will receive this and break its loop.
let _ = tx.send(()).await;
// Wait for worker to finish gracefully.
handle.await.expect("Worker panicked");
println!("Shutdown complete.");
}
The biased; directive is important here. Without it, select! picks a random branch if both are ready. If the shutdown signal arrives at the same time as the work completes, you want the shutdown branch to win. The worker should stop, not do more work. The community convention is to use biased; in shutdown loops. It ensures the signal is processed immediately.
The worker breaks out of the loop when it receives the signal. It can then run cleanup code before returning. The main function awaits the handle to ensure the worker has finished. This is graceful shutdown. The task finishes its current unit of work, cleans up, and stops.
Async cancellation only works at await points. If your task blocks the thread, the shutdown signal waits in vain.
Pitfalls and compiler errors
Tasks in Tokio must be Send and 'static. This means the task cannot hold references to local variables in the spawning function. It must own its data. If you try to spawn a closure that captures a non-Send type, the compiler rejects it with E0277 (the trait Send is not implemented). If you capture a reference that doesn't live long enough, you get E0597 (borrowed value does not live long enough).
#[tokio::main]
async fn main() {
let data = vec![1, 2, 3];
// This fails. The closure captures a reference to data.
// The reference is not 'static.
// Error: E0597: `data` does not live long enough
let _handle = tokio::spawn(async {
println!("{:?}", data);
});
}
To fix this, move the data into the task. The task takes ownership.
#[tokio::main]
async fn main() {
let data = vec![1, 2, 3];
// Move data into the task.
// The task owns the data now.
let _handle = tokio::spawn(async move {
println!("{:?}", data);
});
}
Another pitfall is blocking operations. If a task calls a blocking function like std::fs::read_to_string, it blocks the thread. The runtime cannot poll other tasks. The shutdown signal cannot be processed. The application hangs. Use tokio::task::spawn_blocking for blocking work, or use async alternatives.
Blocking the thread defeats the purpose of async. The shutdown signal waits forever.
Decision matrix
Use handle.await when the task has a natural end and you just need to wait for it to finish before proceeding. Use tokio::select! when you need to race the task against a timeout or a shutdown signal, allowing you to cancel the task if the signal arrives first. Use a channel or CancellationToken when you need to notify a running task to stop its loop cooperatively, giving it a chance to clean up resources. Use handle.abort() when you need to cancel a task from outside without dropping the handle immediately. Use tokio::spawn with immediate drop when you want to fire-and-forget a task and don't care if it gets cancelled when the handle is dropped.