Cancellation, Shutdown, and Blocking Work
Rule
Use cooperative cancellation, join owned tasks, put timeouts at operation boundaries, and isolate blocking or CPU-heavy work from Tokio workers.
Why
Async work may be dropped at any .await; explicit teardown and isolation keep state recoverable and the runtime responsive.
Activation
Apply this page to long-lived loops, graceful shutdown, timeouts, cancellation safety, blocking I/O, CPU-heavy work, or teardown.
Do
- Pass an explicit cancellation signal into long-lived tasks.
- Race service-loop work with shutdown using
select!. - Join task owners during teardown and surface failures.
- Put timeouts around external calls, jobs, subprocesses, and shutdown phases.
- Keep inner helpers timeout-free unless they own an operation boundary.
- Make interrupted sections idempotent, restartable, or backed by resumable state.
- Use
spawn_blockingfor bounded blocking or short CPU-heavy work. - Use a dedicated pool, queue, or Rayon for sustained CPU work.
- Drop locks before awaiting, blocking, callbacks, or expensive computation.
Avoid
- Do not rely on dropping a future as important-work shutdown.
- Do not use abort as the normal path when cleanup matters.
- Do not put non-cancel-safe work in
select!without recoverable state. - Do not scatter timeouts around small helpers.
- Do not run blocking I/O or long CPU work on Tokio workers.
- Do not expect a started
spawn_blockingclosure to be cancelled.
Example
#![allow(unused)]
fn main() {
pub async fn run_worker(
mut jobs: mpsc::Receiver<Job>,
shutdown: CancellationToken,
) -> Result<(), WorkerError> {
loop {
let job = tokio::select! {
() = shutdown.cancelled() => return Ok(()),
job = jobs.recv() => job.ok_or(WorkerError::QueueClosed)?,
};
timeout(Duration::from_secs(10), process_job(job))
.await
.map_err(|_| WorkerError::TimedOut)??;
}
}
}
Exceptions
- Abort best-effort tasks that own no cleanup-sensitive state.
- Let short request tasks complete when request drop already owns cancellation.
- Keep known tiny bounded CPU work on Tokio.