Lesson 88 of 94
Async Pitfalls in Production
Async Rust code that compiles is memory-safe and free of data races. It can still hang, stall, lose work, or grow without bound. Those bugs don't show up in unit tests, only under production load.
This lesson collects the failure patterns experienced teams run into, each with a runnable example, how to recognize it, and how to fix it. It builds on Async Rust, Tokio Patterns, and The Runtime & Async Executors.
| Pitfall | Symptom in production | Fix |
|---|---|---|
| Blocking call in async code | latency spikes for unrelated requests, timeouts under load | spawn_blocking, rayon, async APIs |
std::sync::Mutex held across .await |
"future is not Send", or a stalled worker |
release before .await, or tokio::sync::Mutex |
Non-cancel-safe future in select! |
lost messages or bytes, rarely and randomly | create the future once, or use cancel-safe operations |
| Request future dropped midway | half-finished side effects | spawn work that must complete; idempotent steps |
| Unbounded channel or spawning | memory grows until OOM kill | bounded channels, semaphores, buffer_unordered |
Dropped JoinHandle |
tasks that live forever, silent failures | JoinSet, abort on drop, supervise |
| Future not awaited | the work never happens | #[must_use] warnings, -D warnings |
| Huge futures | stack overflows, slow moves | Box::pin large parts, clippy::large_futures |
| No timeouts | stuck requests and exhausted connection pools | tokio::time::timeout on every remote call |
Part 1: Blocking the runtime
1. One blocking call stalls every task on that worker
Tokio runs many tasks on a few worker threads and can only switch tasks at .await (The Runtime & Async Executors). A task that calls a blocking function keeps its worker for the whole call, and every other task queued on that worker waits. With one worker, the effect is easy to see:
use std::time::{Duration, Instant};
async fn handler_blocking(id: u32) -> u32 {
std::thread::sleep(Duration::from_millis(100)); // blocks the worker thread
id
}
async fn handler_async(id: u32) -> u32 {
tokio::time::sleep(Duration::from_millis(100)).await; // yields the worker
id
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let start = Instant::now();
let tasks: Vec<_> = (0..5).map(|i| tokio::spawn(handler_blocking(i))).collect();
for t in tasks {
t.await.unwrap();
}
let blocking = start.elapsed();
let start = Instant::now();
let tasks: Vec<_> = (0..5).map(|i| tokio::spawn(handler_async(i))).collect();
for t in tasks {
t.await.unwrap();
}
let non_blocking = start.elapsed();
println!("5 blocking handlers took at least 500 ms: {}", blocking >= Duration::from_millis(500));
println!("5 async handlers took under 200 ms: {}", non_blocking < Duration::from_millis(200));
}5 blocking handlers took at least 500 ms: true
5 async handlers took under 200 ms: trueOn the multi-thread runtime, the effect is diluted but not gone: a service with 8 workers and 8 requests blocked in a slow call serves nobody else.
2. Where blocking hides
Blocking code rarely looks like thread::sleep. These are the usual suspects:
| Looks harmless | Why it blocks | Async alternative |
|---|---|---|
std::fs::read_to_string |
synchronous file I/O | tokio::fs, or spawn_blocking for many operations |
"db.internal:5432".to_socket_addrs() |
synchronous DNS lookup | tokio::net::lookup_host, or let the client resolve |
reqwest::blocking, postgres (sync), diesel |
synchronous network drivers | the async client, or spawn_blocking |
std::sync::Mutex::lock on a contended lock |
waits for another thread | keep critical sections tiny, or tokio::sync::Mutex |
serde_json::from_slice on a 50 MB body |
CPU-bound for tens of milliseconds | spawn_blocking or rayon |
| bcrypt/argon2 password hashing, compression, image resizing | CPU-bound by design | spawn_blocking or rayon |
println! to a slow terminal or pipe, log with a synchronous appender |
blocking writes | non-blocking writer (tracing-appender) |
std::sync::mpsc::Receiver::recv, Condvar::wait, thread::join |
wait for another thread | tokio::sync channels, oneshot, JoinHandle |
A useful rule of thumb from the Tokio team: code should not go more than 10 to 100 microseconds without reaching an .await.
3. Detecting a blocked runtime with a heartbeat task
In production, the tell-tale sign is scheduling lag: tasks that should run every few milliseconds run late. A heartbeat task that measures its own lateness turns that into a metric you can alert on.
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
async fn heartbeat(max_lag_ms: Arc<AtomicU64>) {
let period = Duration::from_millis(10);
loop {
let before = Instant::now();
tokio::time::sleep(period).await;
let lag = before.elapsed().saturating_sub(period);
max_lag_ms.fetch_max(lag.as_millis() as u64, Ordering::Relaxed);
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let max_lag = Arc::new(AtomicU64::new(0));
let hb = tokio::spawn(heartbeat(Arc::clone(&max_lag)));
tokio::time::sleep(Duration::from_millis(50)).await;
println!("lag with a healthy runtime under 20 ms: {}", max_lag.load(Ordering::Relaxed) < 20);
// A request handler that parses a big payload synchronously:
tokio::spawn(async {
std::thread::sleep(Duration::from_millis(150)); // stands in for 150 ms of CPU work
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(30)).await;
println!("lag after a blocking task at least 100 ms: {}", max_lag.load(Ordering::Relaxed) >= 100);
hb.abort();
}lag with a healthy runtime under 20 ms: true
lag after a blocking task at least 100 ms: trueExport the lag as a gauge and alert on it. To find which task blocks, use tokio-console (it flags tasks that were busy for a long time in one poll), or a CPU profile of the worker threads.
4. Moving CPU work off the runtime
For CPU-heavy work, spawn_blocking works but its pool allows up to 512 threads, far more than you have cores. A bounded CPU pool such as rayon, bridged back to async with a oneshot channel, keeps the async workers free and the CPU work at one thread per core.
use tokio::sync::oneshot;
fn expensive_checksum(data: &[u8]) -> u64 {
data.iter().fold(0xcbf29ce484222325u64, |h, &b| (h ^ b as u64).wrapping_mul(0x100000001b3))
}
/// Runs `f` on rayon's thread pool and awaits the result without blocking.
async fn run_cpu<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = oneshot::channel();
rayon::spawn(move || {
let _ = tx.send(f());
});
rx.await.expect("the rayon task panicked")
}
#[tokio::main]
async fn main() {
let payloads: Vec<Vec<u8>> = (0..8).map(|i| vec![i as u8; 2_000_000]).collect();
let handles: Vec<_> = payloads.into_iter().map(|p| tokio::spawn(run_cpu(move || expensive_checksum(&p)))).collect();
let mut sums = Vec::new();
for h in handles {
sums.push(h.await.unwrap());
}
println!("{} checksums, all distinct: {}", sums.len(), sums.iter().collect::<std::collections::HashSet<_>>().len() == sums.len());
}8 checksums, all distinct: true| Work | Put it on |
|---|---|
| waiting for sockets, timers, channels | the async runtime |
| blocking I/O (files, sync drivers) | spawn_blocking |
| CPU-bound computation | rayon (or a dedicated, bounded thread pool) |
| a long-lived blocking loop (a sync consumer) | its own std::thread |
Part 2: Locks and .await
5. A std::sync::Mutex guard held across .await
Holding a std::sync::MutexGuard across an .await is a bug twice over: the lock stays held while the task is suspended (so other threads block on it), and the guard isn't Send, so the future can't move between workers. tokio::spawn rejects it:
use std::sync::{Arc, Mutex};
async fn save(_v: u64) {}
#[tokio::main]
async fn main() {
let counter = Arc::new(Mutex::new(0u64));
let c = Arc::clone(&counter);
tokio::spawn(async move {
let mut n = c.lock().unwrap();
*n += 1;
save(*n).await; // the guard is still alive here
});
}error: future cannot be sent between threads safely
--> src/main.rs:9:5
|
9 | / tokio::spawn(async move {
10 | | let mut n = c.lock().unwrap();
11 | | *n += 1;
12 | | save(*n).await; // the guard is still alive here
13 | | });
| |______^ future created by async block is not `Send`
|
= help: within `{async block@src/main.rs:9:18: 9:28}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, u64>`
note: future is not `Send` as this value is used across an await
--> src/main.rs:12:18
|
10 | let mut n = c.lock().unwrap();
| ----- has type `std::sync::MutexGuard<'_, u64>` which is not `Send`
11 | *n += 1;
12 | save(*n).await; // the guard is still alive here
| ^^^^^ await occurs here, with `mut n` maybe used later
note: required by a bound in `tokio::spawn`
--> ~/.cargo/registry/src/.../tokio-1.53.1/src/task/spawn.rs:176:21
|
174 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
| ----- required by a bound in this function
175 | where
176 | F: Future + Send + 'static,
| ^^^^ required by this bound in `spawn`6. Fix: release the lock before .await, or use an async mutex
Most of the time the fix is to copy what you need out of the lock in a small block, and await afterwards. When the lock really must be held across an .await (serializing access to a connection, for example), use tokio::sync::Mutex, whose guard is Send and whose lock waits asynchronously.
use std::sync::{Arc, Mutex};
use std::time::Duration;
async fn save(v: u64) -> u64 {
tokio::time::sleep(Duration::from_millis(1)).await;
v
}
#[tokio::main]
async fn main() {
// Fix 1: std Mutex, guard dropped before the .await.
let counter = Arc::new(Mutex::new(0u64));
let tasks: Vec<_> = (0..10)
.map(|_| {
let c = Arc::clone(&counter);
tokio::spawn(async move {
let value = {
let mut n = c.lock().unwrap();
*n += 1;
*n
}; // guard dropped here
save(value).await
})
})
.collect();
for t in tasks {
t.await.unwrap();
}
println!("std mutex: {}", counter.lock().unwrap());
// Fix 2: tokio Mutex, when the critical section itself must await.
let conn = Arc::new(tokio::sync::Mutex::new(Vec::<u64>::new()));
let tasks: Vec<_> = (0..10)
.map(|i| {
let conn = Arc::clone(&conn);
tokio::spawn(async move {
let mut log = conn.lock().await;
let saved = save(i).await; // allowed: the guard is Send
log.push(saved);
})
})
.collect();
for t in tasks {
t.await.unwrap();
}
println!("tokio mutex: {} writes", conn.lock().await.len());
}std mutex: 10
tokio mutex: 10 writesPrefer the std mutex for plain data with short critical sections: it's faster, and the compiler stops you from holding it across .await in spawned tasks. Note that the compiler only checks Send; on a current-thread runtime or inside block_on, holding a std guard across .await compiles and can deadlock. Clippy's await_holding_lock lint catches it everywhere.
7. Actors: avoiding shared locks entirely
For state that many tasks update, an actor often beats any mutex: one task owns the state, and others send it messages. There's no lock to hold across .await, and requests are processed in order.
use std::collections::HashMap;
use tokio::sync::{mpsc, oneshot};
enum Command {
Add { user: String, amount: i64 },
Balance { user: String, reply: oneshot::Sender<i64> },
}
async fn ledger_actor(mut inbox: mpsc::Receiver<Command>) {
let mut balances: HashMap<String, i64> = HashMap::new(); // owned by this task only
while let Some(cmd) = inbox.recv().await {
match cmd {
Command::Add { user, amount } => *balances.entry(user).or_default() += amount,
Command::Balance { user, reply } => {
let _ = reply.send(balances.get(&user).copied().unwrap_or(0));
}
}
}
}
#[tokio::main]
async fn main() {
let (tx, rx) = mpsc::channel(64);
let actor = tokio::spawn(ledger_actor(rx));
let writers: Vec<_> = (0..4)
.map(|_| {
let tx = tx.clone();
tokio::spawn(async move {
for _ in 0..25 {
tx.send(Command::Add { user: "ana".into(), amount: 10 }).await.unwrap();
}
})
})
.collect();
for w in writers {
w.await.unwrap();
}
let (reply, answer) = oneshot::channel();
tx.send(Command::Balance { user: "ana".into(), reply }).await.unwrap();
println!("ana: {}", answer.await.unwrap());
drop(tx); // closing the channel stops the actor
actor.await.unwrap();
}ana: 1000Part 3: Cancellation
8. select! drops the losing branches, and their progress
tokio::select! polls several futures and, as soon as one completes, drops the others. If a dropped future had already done part of its work (taken an item from a queue, read some bytes), that work is lost. In a loop, this happens on every iteration where the other branch wins.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::Duration;
type Queue = Arc<Mutex<VecDeque<u32>>>;
/// NOT cancel-safe: it removes the job first, then awaits.
async fn take_and_process(queue: Queue) -> Option<u32> {
let job = queue.lock().unwrap().pop_front()?;
tokio::time::sleep(Duration::from_millis(30)).await; // "process" the job
Some(job)
}
#[tokio::main]
async fn main() {
let queue: Queue = Arc::new(Mutex::new((1..=5).collect()));
let mut ticker = tokio::time::interval(Duration::from_millis(10));
let mut done = Vec::new();
loop {
tokio::select! {
job = take_and_process(Arc::clone(&queue)) => match job {
Some(j) => done.push(j),
None => break,
},
_ = ticker.tick() => {} // e.g. report metrics every 10 ms
}
}
println!("completed jobs: {done:?}");
println!("left in queue: {:?}", queue.lock().unwrap());
}completed jobs: []
left in queue: []Five jobs went in and none came out. Processing a job takes 30 ms and the ticker fires every 10 ms, so the ticker always won. Each time, the take_and_process future had already popped a job and was sleeping; select! dropped it and the job vanished. Nothing crashed and nothing was logged. In production this shows up as rare, unexplained data loss.
9. Fixing it: keep one future alive across iterations
There are two fixes. Make the operation cancel-safe (do the irreversible step last, after every .await), or create the future once, outside the loop, and poll the same future in every iteration so it's never dropped halfway.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::Duration;
type Queue = Arc<Mutex<VecDeque<u32>>>;
async fn take_and_process(queue: Queue) -> Option<u32> {
let job = queue.lock().unwrap().pop_front()?;
tokio::time::sleep(Duration::from_millis(30)).await;
Some(job)
}
#[tokio::main]
async fn main() {
let queue: Queue = Arc::new(Mutex::new((1..=5).collect()));
let mut ticker = tokio::time::interval(Duration::from_millis(10));
let mut done = Vec::new();
let mut ticks = 0;
let mut current = Box::pin(take_and_process(Arc::clone(&queue)));
loop {
tokio::select! {
job = &mut current => match job { // poll the same future: it keeps its progress
Some(j) => {
done.push(j);
current = Box::pin(take_and_process(Arc::clone(&queue))); // start the next one
}
None => break,
},
_ = ticker.tick() => ticks += 1,
}
}
println!("completed jobs: {done:?}");
println!("ticker still ran: {}", ticks > 5);
}completed jobs: [1, 2, 3, 4, 5]
ticker still ran: true&mut current polls the future without moving it into select!, so losing the race only pauses it. (tokio::pin! does the same on the stack for a future that never needs to be replaced.)
10. Which operations are cancel-safe?
Tokio documents cancel safety on each method. The ones that come up constantly:
| Operation | Cancel-safe? | If cancelled |
|---|---|---|
mpsc::Receiver::recv, broadcast::Receiver::recv |
yes | no message was taken |
mpsc::Sender::send |
yes, but | the message wasn't sent and is dropped |
AsyncReadExt::read |
yes | no data was read |
AsyncBufReadExt::lines() then next_line |
yes | no line was consumed |
AsyncReadExt::read_exact, read_to_end |
no | some bytes may have been read and lost |
AsyncBufReadExt::read_line |
no | partial line lost |
AsyncWriteExt::write_all |
no | an unknown prefix was written |
tokio::time::sleep, Interval::tick |
yes | nothing happens |
your own async fn |
only if you made it so | whatever it had done before its current .await |
The rule for select! in a loop: every branch must be cancel-safe, or be a future created outside the loop.
11. Cancelled requests leave half-finished work
The same problem exists without select!. When a client disconnects or a timeout fires, the web framework drops the request handler's future. Everything after the current .await never runs, including the second half of a two-step update.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::time::timeout;
#[derive(Default, Debug)]
struct Accounts {
alice: i64,
bob: i64,
}
async fn slow_network_call() {
tokio::time::sleep(Duration::from_millis(100)).await;
}
async fn transfer(accounts: Arc<Mutex<Accounts>>, amount: i64) {
accounts.lock().unwrap().alice -= amount;
slow_network_call().await; // e.g. notify a fraud-check service
accounts.lock().unwrap().bob += amount; // never runs if the future is dropped above
}
#[tokio::main]
async fn main() {
let accounts = Arc::new(Mutex::new(Accounts { alice: 100, bob: 0 }));
// The client gives up after 20 ms, so the handler future is dropped.
let result = timeout(Duration::from_millis(20), transfer(Arc::clone(&accounts), 30)).await;
println!("timed out: {}", result.is_err());
println!("after a cancelled request: {:?}", accounts.lock().unwrap());
// Fix: run work that must complete as its own task; the request only waits for it.
let accounts = Arc::new(Mutex::new(Accounts { alice: 100, bob: 0 }));
let job = tokio::spawn(transfer(Arc::clone(&accounts), 30));
let result = timeout(Duration::from_millis(20), async { job.await.unwrap() }).await;
println!("timed out: {}", result.is_err());
tokio::time::sleep(Duration::from_millis(150)).await; // the spawned task finishes on its own
println!("with a spawned task: {:?}", accounts.lock().unwrap());
}timed out: true
after a cancelled request: Accounts { alice: 70, bob: 0 }
timed out: true
with a spawned task: Accounts { alice: 70, bob: 30 }A spawned task isn't cancelled when the JoinHandle (or a future awaiting it) is dropped, so the transfer completes. Better still, design multi-step operations so they can be interrupted at any .await: a single database transaction, idempotent steps with a retry, or an outbox table that a background worker completes.
Part 4: Unbounded growth
12. Unbounded channels hide overload until memory runs out
An unbounded channel never makes the sender wait. When the consumer is slower than the producer, the queue grows until the process is killed. A bounded channel applies back-pressure: send waits when the channel is full, which slows the producer to the consumer's pace.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
// Unbounded: the producer finishes immediately, everything piles up in memory.
let (tx, mut rx) = mpsc::unbounded_channel::<Vec<u8>>();
for _ in 0..10_000 {
tx.send(vec![0u8; 1024]).unwrap();
}
println!("unbounded: {} messages ({} KiB) waiting", rx.len(), rx.len());
while rx.try_recv().is_ok() {}
// Bounded: at most 100 messages in flight, the producer waits for the consumer.
let (tx, mut rx) = mpsc::channel::<Vec<u8>>(100);
let max_seen = Arc::new(AtomicUsize::new(0));
let seen = Arc::clone(&max_seen);
let consumer = tokio::spawn(async move {
let mut n = 0;
while let Some(_msg) = rx.recv().await {
seen.fetch_max(rx.len(), Ordering::Relaxed);
n += 1;
if n % 1000 == 0 {
tokio::time::sleep(Duration::from_millis(1)).await; // a slow consumer
}
}
n
});
for _ in 0..10_000 {
tx.send(vec![0u8; 1024]).await.unwrap(); // waits when 100 are queued
}
drop(tx);
println!("bounded: consumed {}, queue never above {}: {}", consumer.await.unwrap(), 100, max_seen.load(Ordering::Relaxed) <= 100);
}unbounded: 10000 messages (10000 KiB) waiting
bounded: consumed 10000, queue never above 100: trueUse bounded channels by default and choose what happens when they're full: wait (send().await), reject (try_send returns an error you can turn into HTTP 503), or drop the oldest (broadcast). An unbounded channel is only right when the number of messages is bounded by something else.
13. Unbounded concurrency: spawning one task per item
Spawning a task per item, or join_all over a million futures, starts all of them at once. Against a remote service that means a million simultaneous requests: connection pool exhaustion, rate limiting, or a self-inflicted DoS. Limit concurrency with buffer_unordered (or a Semaphore, shown in Tokio Patterns).
use futures::stream::{self, StreamExt};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
async fn fetch(id: u32, in_flight: Arc<AtomicUsize>, peak: Arc<AtomicUsize>) -> u32 {
let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(now, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(5)).await; // a remote call
in_flight.fetch_sub(1, Ordering::SeqCst);
id * 2
}
#[tokio::main]
async fn main() {
let in_flight = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
let results: Vec<u32> = stream::iter(0..200)
.map(|id| fetch(id, Arc::clone(&in_flight), Arc::clone(&peak)))
.buffer_unordered(16) // at most 16 requests at a time
.collect()
.await;
println!("fetched {} items", results.len());
println!("peak concurrency: {}", peak.load(Ordering::SeqCst));
}fetched 200 items
peak concurrency: 16buffer_unordered(n) yields results as they complete; buffered(n) keeps the input order. Choose n from what the downstream system can take, not from how fast you'd like to go.
14. A dropped JoinHandle doesn't stop the task
In Tokio, dropping a JoinHandle detaches the task. It runs until it finishes on its own, which for a loop means forever. Use abort(), or a JoinSet, which aborts all its tasks when it's dropped.
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use tokio::task::JoinSet;
async fn poll_forever(counter: Arc<AtomicU32>) {
loop {
counter.fetch_add(1, Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
#[tokio::main]
async fn main() {
let detached = Arc::new(AtomicU32::new(0));
{
let _handle = tokio::spawn(poll_forever(Arc::clone(&detached)));
} // handle dropped: the task keeps running
let owned = Arc::new(AtomicU32::new(0));
{
let mut set = JoinSet::new();
set.spawn(poll_forever(Arc::clone(&owned)));
tokio::time::sleep(Duration::from_millis(20)).await;
} // JoinSet dropped: its tasks are aborted
let (d1, o1) = (detached.load(Ordering::Relaxed), owned.load(Ordering::Relaxed));
tokio::time::sleep(Duration::from_millis(50)).await;
let (d2, o2) = (detached.load(Ordering::Relaxed), owned.load(Ordering::Relaxed));
println!("detached task still running: {}", d2 > d1);
println!("JoinSet task stopped: {}", o2 == o1);
}detached task still running: true
JoinSet task stopped: trueEvery spawned task should have an owner that either awaits it, aborts it, or is itself shut down. tokio_util::task::AbortOnDropHandle wraps a single JoinHandle with the same abort-on-drop behavior.
15. A future that is never awaited never runs
Calling an async fn only creates a future. Forgetting .await means the work silently doesn't happen. The compiler warns (unused_must_use: "futures do nothing unless you .await or poll them"), but let _ = ... silences it:
use std::sync::atomic::{AtomicU32, Ordering};
static AUDIT_EVENTS: AtomicU32 = AtomicU32::new(0);
async fn audit(_event: &str) {
AUDIT_EVENTS.fetch_add(1, Ordering::Relaxed);
}
#[tokio::main]
async fn main() {
let _ = audit("login"); // BUG: creates the future and drops it
audit("logout").await; // runs
tokio::spawn(audit("password change")).await.unwrap(); // runs, as its own task
println!("audit events recorded: {}", AUDIT_EVENTS.load(Ordering::Relaxed));
}audit events recorded: 2Build with warnings as errors in CI (RUSTFLAGS="-D warnings" or cargo clippy -- -D warnings), and treat let _ = on a future as a review red flag. Clippy's let_underscore_future lint flags exactly this line.
16. Oversized futures
An async fn's future stores every local variable that lives across an .await. A large buffer on the "stack" of an async function makes the future large, and futures are moved when they're spawned or nested. Very large futures cause slow copies and, when they end up on a thread's real stack, stack overflows.
use std::mem::size_of_val;
async fn tiny() -> u8 {
tokio::task::yield_now().await;
1
}
async fn big_buffer() -> usize {
let buf = [0u8; 64 * 1024]; // lives across the .await below
tokio::task::yield_now().await;
buf.len()
}
async fn boxed_buffer() -> usize {
let buf = vec![0u8; 64 * 1024]; // only a pointer lives in the future
tokio::task::yield_now().await;
buf.len()
}
async fn nested() -> usize {
big_buffer().await + big_buffer().await // the parent embeds its children's state
}
#[tokio::main]
async fn main() {
println!("tiny: {} bytes", size_of_val(&tiny()));
println!("big_buffer: {} bytes", size_of_val(&big_buffer()));
println!("boxed_buffer: {} bytes", size_of_val(&boxed_buffer()));
println!("nested: {} bytes", size_of_val(&nested()));
println!("Box::pin(nested()): {} bytes", size_of_val(&Box::pin(nested())));
}tiny: 24 bytes
big_buffer: 65560 bytes
boxed_buffer: 48 bytes
nested: 65576 bytes
Box::pin(nested()): 8 bytesThe parent future is as big as its largest child (the two big_buffer calls don't overlap, so their space is shared). Keep large buffers on the heap (Vec, Box), box large child futures with Box::pin, and enable clippy::large_futures to flag futures above a size threshold.
Part 5: Timeouts and shutdown
17. Every remote call needs a timeout
A network call without a timeout can wait forever: a peer that accepted the connection and then stopped responding, a load balancer that silently drops packets. Each stuck call holds a task, a connection from the pool, and whatever memory it allocated. Put a timeout around every remote call, and an overall deadline around the request.
use std::time::Duration;
use tokio::time::{Instant, timeout, timeout_at};
async fn call(service: &str, latency_ms: u64) -> String {
tokio::time::sleep(Duration::from_millis(latency_ms)).await;
format!("{service} ok")
}
#[tokio::main]
async fn main() {
let deadline = Instant::now() + Duration::from_millis(100); // the whole request's budget
let user = timeout(Duration::from_millis(50), call("users", 10)).await;
println!("users: {user:?}");
let prices = timeout(Duration::from_millis(50), call("prices", 5_000)).await; // a hung dependency
println!("prices: {}", prices.map_err(|_| "timed out after 50 ms").unwrap_err());
let rest = timeout_at(deadline, call("recommendations", 80)).await; // only ~40 ms left
println!("recommendations: {}", rest.map(|_| "ok").unwrap_or("skipped: request deadline reached"));
}users: Ok("users ok")
prices: timed out after 50 ms
recommendations: skipped: request deadline reachedHTTP and database clients also have their own settings (connect timeout, request timeout, pool acquire timeout). Set all of them: the defaults are often "wait forever".
18. Graceful shutdown with CancellationToken
On SIGTERM, a service should stop accepting work, let in-flight work finish (up to a deadline), flush what needs flushing, and exit. tokio_util::sync::CancellationToken broadcasts the signal to every task, and a JoinSet waits for them.
use std::time::Duration;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
async fn worker(id: u32, token: CancellationToken) -> String {
let mut processed = 0;
loop {
tokio::select! {
_ = token.cancelled() => break, // stop taking new work
_ = tokio::time::sleep(Duration::from_millis(5)) => processed += 1, // one unit of work
}
}
// Cleanup that must happen: flush buffers, commit offsets, close connections.
format!("worker {id} stopped cleanly after {} jobs", if processed > 0 { "some" } else { "no" })
}
#[tokio::main]
async fn main() {
let token = CancellationToken::new();
let mut workers = JoinSet::new();
for id in 1..=3 {
workers.spawn(worker(id, token.child_token()));
}
// In a real service: tokio::signal::ctrl_c().await, or a SIGTERM handler.
tokio::time::sleep(Duration::from_millis(30)).await;
println!("shutdown requested");
token.cancel();
let drained = tokio::time::timeout(Duration::from_secs(5), async {
let mut reports = Vec::new();
while let Some(r) = workers.join_next().await {
reports.push(r.unwrap());
}
reports.sort();
reports
})
.await;
match drained {
Ok(reports) => reports.iter().for_each(|r| println!("{r}")),
Err(_) => println!("shutdown deadline hit; remaining tasks are aborted"),
}
}shutdown requested
worker 1 stopped cleanly after some jobs
worker 2 stopped cleanly after some jobs
worker 3 stopped cleanly after some jobsWeb frameworks integrate with this: axum's serve(...).with_graceful_shutdown(token.cancelled_owned()) stops accepting connections and waits for in-flight requests. Kubernetes sends SIGTERM and waits terminationGracePeriodSeconds (30 s by default) before SIGKILL, so the drain deadline must be shorter than that.
19. A review checklist for async code
| Check | Look for |
|---|---|
| No blocking in async | std::fs, std::thread::sleep, sync clients, heavy CPU work, .lock() on contended std mutexes |
| CPU work offloaded | spawn_blocking for blocking I/O, rayon for computation |
| Locks | no std guard across .await (clippy::await_holding_lock); actors or short critical sections |
select! loops |
every branch cancel-safe, or futures created outside the loop |
| Cancellation | multi-step side effects survive the handler being dropped at any .await |
| Bounded everything | channels, spawns, join_all, retries, caches |
| Task ownership | every spawn has a handle that is awaited, aborted, or in a JoinSet |
| Futures awaited | no let _ = some_async_fn(); -D warnings in CI |
| Future size | no large arrays across .await; clippy::large_futures |
| Timeouts | on every remote call, plus a per-request deadline |
| Shutdown | cancellation token, drain with a deadline shorter than the orchestrator's |
| Observability | a scheduling-lag metric, tokio-console in staging, spans per request |
Summary
- The runtime can only switch tasks at
.await. Blocking calls and long computations stall every task on the worker: move them tospawn_blocking,rayon, or a dedicated thread, and watch scheduling lag. - Don't hold a
std::sync::Mutexguard across.await; release it first, usetokio::sync::Mutex, or give the state to an actor. select!drops the losing futures. Use cancel-safe operations, or create the future once and poll it by&mut.- A request's future can be dropped at any
.await. Spawn work that must complete, and make multi-step updates atomic or idempotent. - Bound every queue and every fan-out; unbounded channels and spawns turn overload into OOM kills.
- Dropping a
JoinHandledetaches the task. Give every task an owner (JoinSet, abort on drop, a supervisor). - A future that isn't awaited does nothing. Keep warnings fatal in CI.
- Keep futures small, put timeouts on every remote call, and implement graceful shutdown with a cancellation token and a drain deadline.
Next: Testing, Fuzzing & Benchmarking: property-based tests with proptest, fuzzing with cargo fuzz, benchmarks with criterion, snapshot tests, and mocks.
