I used to wire up my own oneshot::channel for shutdown every time. Then I started using tokio_util::sync::CancellationToken and never went back. It supports multiple clones, child tokens that cancel when the parent does, and a natural fit with tokio::select!.

use tokio::time::{sleep, Duration};
use tokio_util::sync::CancellationToken;

async fn worker(id: usize, cancel: CancellationToken) {
    loop {
        tokio::select! {
            _ = cancel.cancelled() => {
                tracing::info!(id, "shutting down");
                return;
            }
            _ = sleep(Duration::from_secs(1)) => {
                tracing::debug!(id, "tick");
                // real work
            }
        }
    }
}

#[tokio::main]
async fn main() {
    let root = CancellationToken::new();
    let mut handles = Vec::new();
    for id in 0..4 {
        let c = root.child_token();
        handles.push(tokio::spawn(worker(id, c)));
    }

    tokio::signal::ctrl_c().await.ok();
    root.cancel();
    for h in handles { let _ = h.await; }
}

Two things worth knowing:

  1. child_token() means you can cancel a subtree without cancelling the root. I use this to drop a single tenant’s background jobs without disturbing the rest.
  2. cancelled() returns a future, not a &mut, so you can put it in a select! without borrowing headaches.

See also /posts/tokio-sync-vs-std-sync-in-a-real-service/.