How Tokio schedules work, how to own every task you start, how to cancel cleanly, how select! drops
futures, and the exact shutdown sequence for a service behind a load balancer.
Contents
- The runtime model in one page
- Choosing and building the runtime
- Spawning: JoinHandle semantics
- JoinSet: structured groups with results
- TaskTracker: wait for background work
- CancellationToken
- select! in depth
- Cancel safety reference
- Graceful shutdown sequence
- Bounding the drain
- Blocking and CPU-bound work
- Panics in tasks
- Checklist
The runtime model in one page
- A task is a future handed to the scheduler (
tokio::spawn). It runs until it returnsPoll::Pendingat an.awaitwhose resource is not ready, then the worker picks another task. Tasks are cooperative: code between two.awaits runs without interruption. - The multi-thread scheduler runs N worker threads (default: number of CPU cores) with work stealing.
Spawned futures may move between threads at every
.await, so they must beSend + 'static. - The current_thread scheduler runs every task on the thread that called
block_on. Futures still needSendfortokio::spawn(the API is the same), butLocalSet::spawn_localaccepts!Sendfutures. - A separate blocking pool (default cap 512 threads, created lazily) runs
spawn_blockingclosures and the internals oftokio::fs. - Tokio has cooperative budgeting: each task gets a budget of operations per poll; when exhausted, tokio
resources return
Pendingto force a yield. This prevents one busy task looping over a ready channel from starving the worker, but it cannot interrupt your own CPU loops: calltokio::task::yield_now().awaitin long async loops that do real CPU work per iteration, or move the work off the runtime.
What "blocking" means in practice: anything that takes more than ~10-100 µs without reaching an .await. A
40 ms std::fs::read on a worker delays every other task scheduled on that worker by 40 ms.
Choosing and building the runtime
// 1. Macro, multi-thread (default): one worker per core.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
Ok(())
}// 2. Macro with options.
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() -> anyhow::Result<()> {
Ok(())
}// 3. Explicit builder: needed for thread names, blocking pool size, hooks, or running sync code before
// the runtime starts (parsing CLI, loading config, daemonizing).
fn main() -> anyhow::Result<()> {
let workers = std::env::var("WORKERS").ok().and_then(|v| v.parse().ok()).unwrap_or(4);
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(workers)
.max_blocking_threads(64)
.thread_name("svc-worker")
.thread_stack_size(4 * 1024 * 1024)
.enable_all()
.build()?;
rt.block_on(async_main())
}
async fn async_main() -> anyhow::Result<()> {
Ok(())
}Container notes:
std::thread::available_parallelism()(what tokio uses for the default) respects cgroup v2 CPU quotas on Linux in recent Rust versions, but fractional limits round up. Setworker_threadsexplicitly when the pod limit is small (1-2 CPUs) so you do not run 2 busy workers on 1 CPU of quota.enable_all()is required with the builder; withoutenable_time()tokio::time::sleeppanics with "A Tokio 1.x context was found, but timers are disabled".- The error "there is no reactor running, must be called from the context of a Tokio 1.x runtime" means a
tokio resource (
TcpStream,sleep,spawn) was used outsideblock_on/ a runtime thread, often from astd::threador a different executor (futures::executor::block_on). Pass atokio::runtime::Handleto that thread and callhandle.spawn(..)/handle.block_on(..)there.
| Flavor | Pick when | Avoid when |
|---|---|---|
| multi_thread | servers, anything with parallel load | you need !Send state everywhere |
| current_thread | CLIs, tests, one-connection clients, embedding a runtime per thread (thread-per-core designs) | any CPU or blocking work on the async path (it stalls everything) |
| LocalSet on either | a few !Send futures (e.g. a !Send SDK) | as a workaround for a MutexGuard across await (fix the code instead) |
Spawning: JoinHandle semantics
let handle: tokio::task::JoinHandle<u64> = tokio::spawn(async move { compute().await });
match handle.await {
Ok(value) => tracing::info!(value, "done"),
Err(err) if err.is_panic() => tracing::error!("task panicked"),
Err(err) if err.is_cancelled() => tracing::warn!("task was aborted"),
Err(err) => tracing::error!(%err, "join error"),
}Facts that matter:
- Dropping a
JoinHandledoes not cancel the task. It detaches it. The task keeps running, its output and any panic are discarded, and nothing waits for it at shutdown (lint TK009). handle.abort()requests cancellation: the task is dropped at its next.awaitpoint. A task inside a long synchronous section is not interrupted.spawn_blockingclosures cannot be aborted once started.handle.abort_handle()gives a cloneable handle that can abort without owning the result.- When the runtime shuts down (end of
#[tokio::main]), all remaining tasks are dropped at their next.await; destructors run, but code after the.awaitnever does. Work you care about must be awaited beforemainreturns. tokio::spawnrequiresF: Future + Send + 'staticandF::Output: Send + 'static.
JoinSet: structured groups with results
JoinSet owns a set of tasks: results come back in completion order, and dropping the set aborts every
task in it. Use it for fan-out where you need the results or the errors.
use std::{sync::Arc, time::Duration};
use tokio::{sync::Semaphore, task::JoinSet};
async fn fetch_all(http: reqwest::Client, urls: Vec<String>) -> anyhow::Result<Vec<(String, u16)>> {
let limit = Arc::new(Semaphore::new(16)); // at most 16 requests in flight
let mut set = JoinSet::new();
for url in urls {
let http = http.clone();
let permit = limit.clone().acquire_owned().await?; // backpressure on the spawner too
set.spawn(async move {
let _permit = permit; // released when the task ends
let res = http.get(&url).timeout(Duration::from_secs(5)).send().await?;
Ok::<_, reqwest::Error>((url, res.status().as_u16()))
});
}
let mut out = Vec::with_capacity(set.len());
while let Some(joined) = set.join_next().await {
match joined {
Ok(Ok(row)) => out.push(row),
Ok(Err(err)) => tracing::warn!(%err, "fetch failed"),
Err(join_err) => return Err(join_err.into()), // panic or abort: stop, set drop aborts the rest
}
}
Ok(out)
}set.join_all().await(tokio 1.40+) collects every output and panics if a task panicked; use thejoin_nextloop when you need per-task error handling.set.abort_all()cancels everything;set.shutdown().awaitaborts and waits.set.spawn_blocking(..)adds a blocking closure to the same set.- Do not keep pushing into a
JoinSetforever without draining it: finished tasks hold their output until joined. For a long-running server, usetry_join_next()in the loop or aTaskTracker.
TaskTracker: wait for background work
tokio_util::task::TaskTracker (feature rt) tracks tasks without storing their outputs, so it does not
grow with completed tasks. It is the right owner for "fire and track" work: audit writes, webhooks, cache
refresh.
use tokio_util::{sync::CancellationToken, task::TaskTracker};
let tracker = TaskTracker::new();
let token = CancellationToken::new();
// in a handler (tracker cloned into state; TaskTracker is cheap to clone)
tracker.spawn(async move { send_webhook(event).await });
// at shutdown
token.cancel(); // ask long-running loops to stop
tracker.close(); // wait() only resolves after close() AND every task finished
tracker.wait().await;TaskTracker vs JoinSet:
| JoinSet | TaskTracker | |
|---|---|---|
| Get each task's output | yes (join_next) | no (log inside the task) |
| Dropping it | aborts all tasks | tasks keep running |
| Memory for finished tasks | kept until joined | released immediately |
| Wait for all | loop join_next | close() + wait() |
| Typical use | fan-out/fan-in inside one request | background work across the whole app |
CancellationToken
tokio_util::sync::CancellationToken is a cloneable, hierarchical "please stop" signal.
use std::time::Duration;
use tokio_util::sync::CancellationToken;
async fn consumer(token: CancellationToken, mut rx: tokio::sync::mpsc::Receiver<Job>) {
loop {
tokio::select! {
biased;
_ = token.cancelled() => {
// drain what is already queued, but accept nothing new
rx.close();
while let Some(job) = rx.recv().await {
process(job).await;
}
break;
}
maybe = rx.recv() => match maybe {
Some(job) => process(job).await,
None => break, // all senders dropped
},
}
}
}token.child_token()is cancelled when the parent is, but cancelling a child does not cancel the parent: one token per subsystem, all children of the root.token.cancelled()borrows the token;token.clone().cancelled_owned()returns a'staticfuture you can pass towith_graceful_shutdownor move into a task.token.drop_guard()returns a guard that cancels the token when dropped: tie a subsystem's lifetime to a scope.- Cancellation is cooperative: code must reach a
select!oncancelled()(or be aborted). A task stuck inprocess(job).awaitwill finish that job first; wrap long jobs intokio::time::timeoutif that matters.
select! in depth
tokio::select! {
biased; // optional: poll branches top to bottom
_ = token.cancelled() => { /* shutdown */ }
Some(msg) = rx.recv() => { handle(msg).await } // pattern: if it does not match, the branch is disabled
res = &mut in_flight, if !done => { done = true; res?; } // precondition; poll a pinned future by ref
else => { /* every branch disabled */ }
}Rules:
- All branch futures are created, then polled concurrently on the same task (not in parallel). When one completes, the others are dropped. That drop is cancellation.
- Without
biased;, tokio picks a random starting branch each time for fairness. Usebiased;when the order matters (shutdown first, or high-priority queue first), knowing a hot first branch can starve the rest. - The handler (
=> { .. }) runs after the other futures are dropped, so.awaitin the handler is not raced: a slow handler delays the next loop iteration, nothing more. - To keep a future alive across iterations, create it outside the loop, pin it, and poll
&mut fut:
let sleep = tokio::time::sleep(Duration::from_secs(30));
tokio::pin!(sleep);
loop {
tokio::select! {
_ = &mut sleep => { tracing::warn!("idle for 30s"); break; }
Some(frame) = frames.next() => {
handle(frame).await;
sleep.as_mut().reset(tokio::time::Instant::now() + Duration::from_secs(30)); // idle timeout
}
}
}A sleep(..) written inside the select! in a loop is recreated every iteration; if the other branch fires
every 10 s the 30 s timeout never fires (lint TK016). That is correct only when you want an idle timeout and
you intend the reset.
Cancel safety reference
A future is cancel safe if dropping it before completion loses nothing. From the tokio docs:
Cancel safe (fine in a select! loop) | Not cancel safe (create outside, or use another API) |
|---|---|
mpsc::Receiver::recv, UnboundedReceiver::recv | AsyncReadExt::read_exact, read_to_end, read_to_string |
broadcast::Receiver::recv | AsyncBufReadExt::read_line, read_until |
watch::Receiver::changed | AsyncWriteExt::write_all, write_all_buf |
TcpListener::accept, UnixListener::accept | Mutex::lock, RwLock::read/write, Semaphore::acquire (lose FIFO place; no data loss) |
AsyncReadExt::read, read_buf; AsyncWriteExt::write, write_buf | mpsc::Sender::send (the message moved into the future is dropped) |
tokio::io::Lines::next_line, tokio_stream::StreamExt::next | tokio::io::copy |
tokio::time::Interval::tick, sleep polled by &mut | any async fn of your own unless you checked |
CancellationToken::cancelled, JoinSet::join_next, oneshot::Receiver by &mut |
For framed protocols use tokio_util::codec::FramedRead (+ StreamExt::next, cancel safe): the codec keeps
partial frames in its buffer. For sends, use Sender::reserve() in the select (cancel safe) and then
permit.send(msg) in the handler.
Graceful shutdown sequence
What Kubernetes does on a rollout: marks the pod terminating, removes it from Service endpoints
(asynchronously, takes a few seconds to propagate), runs preStop, sends SIGTERM, waits
terminationGracePeriodSeconds (default 30), then SIGKILL.
The service should:
- Catch SIGTERM and Ctrl+C (
tokio::signal::unix::signal(SignalKind::terminate()),tokio::signal::ctrl_c()). - Flip readiness to 503 so no new traffic is routed; optionally wait a few seconds for endpoint
propagation (or use a
preStop: sleep 5hook). - Stop accepting connections and drain in-flight requests (
with_graceful_shutdown). - Cancel background loops (
CancellationToken), close theTaskTracker, wait with a deadline. - Flush telemetry, close pools (
pool.close().awaitfor sqlx), exit 0 before the grace period ends.
use std::{
sync::{Arc, atomic::{AtomicBool, Ordering}},
time::Duration,
};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
async fn wait_for_signal() {
let ctrl_c = async { tokio::signal::ctrl_c().await.expect("install Ctrl+C handler") };
#[cfg(unix)]
let term = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = term => {} }
}
async fn serve(app: axum::Router, ready: Arc<AtomicBool>, db: sqlx::PgPool) -> anyhow::Result<()> {
let token = CancellationToken::new();
let tracker = TaskTracker::new();
tracker.spawn(refresh_loop(token.child_token()));
// signal watcher: readiness first, short propagation delay, then start the drain
let shutdown = token.clone();
let ready_flag = ready.clone();
tokio::spawn(async move {
wait_for_signal().await;
ready_flag.store(false, Ordering::Relaxed);
tokio::time::sleep(Duration::from_secs(5)).await;
shutdown.cancel();
});
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
ready.store(true, Ordering::Relaxed);
axum::serve(listener, app)
.with_graceful_shutdown(token.clone().cancelled_owned())
.await?;
tracker.close();
if tokio::time::timeout(Duration::from_secs(10), tracker.wait()).await.is_err() {
tracing::warn!("background tasks still running at exit");
}
db.close().await;
Ok(())
}
async fn refresh_loop(token: CancellationToken) {
let mut every = tokio::time::interval(Duration::from_secs(60));
loop {
tokio::select! {
_ = token.cancelled() => break,
_ = every.tick() => { /* refresh caches */ }
}
}
}The signal watcher above is a detached spawn on purpose: it lives for the whole process and ends with it.
Mark such lines // noqa: TK009 if you run the linter in strict mode, or move it into the tracker.
Bounding the drain
with_graceful_shutdown waits for every open connection to finish, including long-polling, SSE and
WebSocket connections. Bound it:
let server = axum::serve(listener, app).with_graceful_shutdown(token.clone().cancelled_owned());
let mut server_task = tokio::spawn(async move { server.await });
tokio::select! {
res = &mut server_task => { res??; } // finished on its own
_ = async {
token.cancelled().await; // shutdown started
tokio::time::sleep(Duration::from_secs(20)).await
} => {
tracing::warn!("drain deadline hit, aborting remaining connections");
server_task.abort();
}
}- Give streaming handlers the token too, so they end their streams when it is cancelled.
- Keep the drain deadline below
terminationGracePeriodSecondsminus the readiness delay. TimeoutLayeron the router bounds normal requests, so the drain cannot be held by a stuck handler.
Blocking and CPU-bound work
| Work | Use | Why |
|---|---|---|
| Short sync call (file read, sync SDK call, bcrypt/argon2, image decode) | tokio::task::spawn_blocking(move || ..).await? | blocking pool; await gives the result |
| Many small file ops | one spawn_blocking with std::fs inside | tokio::fs does one pool hop per call |
| Parallel CPU (batch hashing, parsing, scoring) | rayon::spawn / pool.install + oneshot | bounded CPU pool, no oversubscription |
| Long-lived blocking loop (serial port, sync consumer) | std::thread::spawn + channel | does not occupy the blocking pool forever |
| Must block and you are deep in sync code on a multi-thread runtime | tokio::task::block_in_place | converts the worker; panics on current_thread |
| Sync code needs to call async | Handle::block_on from a non-runtime thread | block_on on a runtime thread panics |
use rayon::prelude::*;
async fn score_all(docs: Vec<String>) -> anyhow::Result<Vec<f32>> {
let (tx, rx) = tokio::sync::oneshot::channel();
rayon::spawn(move || {
let scores: Vec<f32> = docs.par_iter().map(|d| d.len() as f32).collect();
let _ = tx.send(scores); // receiver may be gone if the request was cancelled
});
Ok(rx.await?)
}spawn_blocking is not a CPU limiter: 512 concurrent calls create up to 512 threads. Put a Semaphore in
front of it for CPU-heavy closures, or use rayon.
Panics in tasks
- A panic inside
tokio::spawnis caught by the runtime; theJoinHandlereturnsErr(JoinError)withis_panic(). If the handle was dropped, the panic is only printed by the panic hook. - A panic in an axum handler aborts that connection's task: the client sees a closed connection. Add
tower_http::catch_panic::CatchPanicLayer(featurecatch-panic) to turn it into a 500, but fix the panic; do not rely on it. panic = "abort"in the release profile turns any task panic into process exit: decide deliberately.- Poisoned
std::sync::Mutex: after a panic while holding the guard,lock()returnsErr(PoisonError)..lock().expect("... poisoned")propagates the crash;.lock().unwrap_or_else(|e| e.into_inner())keeps going with possibly inconsistent data.parking_lot::Mutexhas no poisoning.
Checklist
- Runtime flavor and worker count chosen for the deployment's CPU limit
- No
tokio::spawn(..);with a dropped handle except the documented process-lifetime ones - Every loop has a
select!branch on aCancellationToken(or ends when its channel closes) -
select!loops only race cancel-safe futures; timers pinned outside the loop - SIGTERM and Ctrl+C handled; readiness flips before the drain; drain has a deadline
- Background tasks tracked (
TaskTracker) and awaited with a timeout at shutdown - CPU and blocking work on
spawn_blocking/rayon, with a concurrency bound - the scanners (
blocking_in_async,await_holding,task_hygiene,service_hygiene) pass with--strict
rust-borrow-checker-error-explainer
Explain and fix Rust borrow-checker, ownership, move and lifetime compiler errors: E0382 use of moved value, E0499 cannot borrow as mutable more than once, E0502 also borrowed as immutable, E0505, E0506, E0503, E0507 cannot move out of index/Rc/reference, E0515 return reference to local, E0597 does not live long enough, E0716 temporary value dropped while borrowed, E0373 closure may outlive (spawn needs move), E0521 borrowed data escapes, E0384, E0596, E0594, E0381, E0713, 'lifetime may not live long enough', RefCell 'already borrowed: BorrowMutError' panics. Ranks fixes from restructuring (split borrows, entry API, split_at_mut, get_disjoint_mut, mem::take, Option::take, indices, thread::scope) down to clone and Rc<RefCell>/Arc<Mutex>; never unsafe. Covers NLL limits (get-or-insert), iterator invalidation, closure captures, self-referential structs. Ships std-only Rust triage tools.
rust-cargo-workspace-designer
Design, review and fix Cargo workspaces: when to split a crate (compile time, boundaries, publishing, proc-macros), crates/* vs apps/ + crates/ layouts, virtual manifest vs root package, members/exclude/default-members, resolver 2 vs 3 (MSRV-aware, edition 2024), [workspace.package] and [workspace.dependencies] inheritance, [workspace.lints], root-only profiles (lto, codegen-units), additive features with dep: and weak ?/ syntax, feature unification pitfalls, internal path + version deps, publish = false, Cargo.lock and MSRV policy, xtask, CI with --locked, nextest, rust-cache, sccache, cargo-hack/deny/machete/udeps/semver-checks, and cargo build --timings. Ships a workspace auditor and crate-graph tool.
rust-error-handling-thiserror-anyhow-designer
Design and review Rust error handling with thiserror 2 and anyhow 1: library vs application split, typed error enums with #[from], #[source], #[error(transparent)] and #[non_exhaustive], ErrorKind patterns, context via .context()/.with_context(), bail!/ensure!, source() chains, {:#} and {:?} reports, RUST_BACKTRACE/RUST_LIB_BACKTRACE, Box<dyn Error + Send + Sync>, main -> anyhow::Result or ExitCode, unwrap/expect/panic policy, Option to Result, collecting iterators of Results, axum IntoResponse mapping, retryable errors, tracing, async Send bounds, testing with matches! and downcast_ref, no_std core::error::Error, and snafu, eyre/color-eyre, miette, error-stack.
rust-lifetime-annotation-advisor
Decide when Rust lifetime annotations are needed and which ones: the three elision rules, structs holding references (struct Parser<'a>), owned vs borrowed (String, &str, Cow<'a, str>, Arc<str>), one vs several lifetimes, 'a: 'b bounds, T: 'static misconceptions, Box<dyn Trait + 'a> default object lifetimes, impl Trait + '_ and edition 2024 capture rules with use<..>, variance and PhantomData, HRTB for<'a> Fn(&'a T), GATs and lending iterators, async fn and tokio::spawn 'static, self-referential structs (ouroboros, self_cell, indices, arenas), Rc/Arc to escape lifetime soup. Ships std-only Rust checkers for redundant, missing, unused and over-restrictive lifetimes.
rust-trait-design-reviewer
Review and design Rust traits and generic APIs: generics vs impl Trait vs dyn Trait (static vs dynamic dispatch), dyn compatibility / object safety (generic methods, Self returns, where Self: Sized, error[E0038] not dyn compatible / cannot be made into an object), associated types vs generic parameters, blanket impls, coherence and orphan rule (E0117, E0119, E0210), newtype, extension, sealed and marker traits, supertraits and trait upcasting (1.86), async fn in traits (1.75) and the Send bound problem (trait_variant, async-trait), RPITIT, GATs, From/Into/TryFrom/AsRef/Borrow, std trait checklist (Debug, Clone, PartialEq, Hash, Default, Display, Error, Send/Sync, serde feature), #[must_use], #[non_exhaustive], semver hazards, Deref abuse, builders, typestate, PhantomData, Box<dyn Error + Send + Sync>, Fn traits, mockall.
rust-unsafe-block-auditor
Audit unsafe Rust for soundness: inventory every unsafe block, unsafe fn, unsafe impl/trait, extern block and unsafe attribute, check // SAFETY: comments and # Safety docs, and classify each as sound, needs-justification or likely UB, proposing safe alternatives first. Covers the five unsafe superpowers, aliasing (Stacked/Tree Borrows), invalid values, MaybeUninit vs mem::zeroed/uninitialized, transmute, slice::from_raw_parts, Vec::set_len/from_raw_parts, get_unchecked, CString::as_ptr dangling, unsafe impl Send/Sync, Pin projection, FFI (extern "C", repr(C), C-unwind, Box::into_raw/from_raw), strict provenance, static mut and &raw, edition 2024 (unsafe extern, #[unsafe(no_mangle)], unsafe_op_in_unsafe_fn, env::set_var), Miri, sanitizers, cargo-fuzz, loom, cargo-geiger and clippy safety lints.