Lesson 40 of 44
Common Pitfalls and How to Debug Them
Most Tokio bugs come from a small set of causes: blocking a worker, holding a lock across an .await, queues without limits, lost work from cancellation, and calls made outside a runtime. This lesson shows each cause as a program that goes wrong, states why, and fixes it. It ends with the tools that locate a hang.
| # | Pitfall | Symptom |
|---|---|---|
| 1 | Blocking in async code | timers fire late, other tasks stall |
| 2 | std guard across .await |
compile error: future is not Send |
| 3 | Unbounded channel | queue grows without limit |
| 4 | Forgotten .await |
the work never runs |
| 5 | Dropped JoinHandle |
a panic goes unnoticed |
| 6 | Cancel-unsafe select! branch |
data is lost |
| 7 | Inconsistent lock order | deadlock |
| 8 | block_on inside async |
panic |
| 9 | Notify missed wakeup |
waiter sleeps forever |
| 10 | std::sync::mpsc in async code |
receiver blocks the worker |
| 11 | Oversized futures | large memory per task |
| 12 | sleep in a loop |
schedule drifts |
| 13 | Unbounded spawn fan-out |
resource exhaustion |
| 14 | spawn before a runtime exists |
panic |
Every broken example runs on a current-thread runtime or with the paused clock, so its output is the same on each run. Time: sleep, timeout, and interval explains the paused clock.
Example 1: Blocking in async code
A call such as std::thread::sleep holds the worker thread and never returns control to the runtime. On a current-thread runtime there is one worker, so nothing else runs. The spawned timer below is due after 10 ms, but it cannot fire while the thread sleeps for 100 ms.
use std::time::Duration;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let timer = tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(10)).await;
println!("timer fired");
});
tokio::task::yield_now().await; // lets the timer task start
std::thread::sleep(Duration::from_millis(100));
println!("blocking call finished");
timer.await.unwrap();
}blocking call finished
timer firedThe timer prints after the blocking call, although it was due 90 ms earlier. On a multi-thread runtime the same code delays only the tasks that share the blocked worker, which makes the bug harder to see.
The fix moves blocking work to the blocking pool with spawn_blocking (Blocking and CPU-Bound Work).
use std::time::Duration;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let timer = tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(10)).await;
println!("timer fired");
});
tokio::task::yield_now().await;
tokio::task::spawn_blocking(|| std::thread::sleep(Duration::from_millis(100)))
.await
.unwrap();
println!("blocking call finished");
timer.await.unwrap();
}timer fired
blocking call finishedThe worker stays free, so the timer fires on time.
Example 2: A std guard across .await
A std::sync::MutexGuard is not Send. A future that keeps one alive across an .await is not Send either, and tokio::spawn on the multi-thread runtime requires Send (Futures and async/await).
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[tokio::main]
async fn main() {
let data = Arc::new(Mutex::new(0));
tokio::spawn(async move {
let mut guard = data.lock().unwrap();
*guard += 1;
tokio::time::sleep(Duration::from_millis(1)).await;
})
.await
.unwrap();
}error: future cannot be sent between threads safely
--> src/main.rs:7:5
|
7 | / tokio::spawn(async move {
8 | | let mut guard = data.lock().unwrap();
9 | | *guard += 1;
10 | | tokio::time::sleep(Duration::from_millis(1)).await;
11 | | })
| |______^ future created by async block is not `Send`
|
= help: within `{async block@src/main.rs:7:18: 7:28}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, i32>`
note: future is not `Send` as this value is used across an await
--> src/main.rs:10:54
|
8 | let mut guard = data.lock().unwrap();
| --------- has type `std::sync::MutexGuard<'_, i32>` which is not `Send`
9 | *guard += 1;
10 | tokio::time::sleep(Duration::from_millis(1)).await;
| ^^^^^ await occurs here, with `mut guard` 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`The compiler rejects the program because the guard is still alive at the .await. The task could resume on another thread, and the standard library does not allow a std mutex guard to move to another thread.
The fix ends the guard's scope before the .await, so the critical section stays short (Shared State: Mutex and RwLock).
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[tokio::main]
async fn main() {
let data = Arc::new(Mutex::new(0));
let worker = Arc::clone(&data);
tokio::spawn(async move {
{
let mut guard = worker.lock().unwrap();
*guard += 1;
} // guard dropped here
tokio::time::sleep(Duration::from_millis(1)).await;
})
.await
.unwrap();
println!("value = {}", data.lock().unwrap());
}value = 1tokio::sync::Mutex is the alternative when the lock must be held across an .await.
Example 3: Unbounded channels
UnboundedSender::send never waits. If the producer is faster than the consumer, the queue grows until memory runs out. This program queues 100 000 messages before the consumer runs once.
use tokio::sync::mpsc;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let (tx, mut rx) = mpsc::unbounded_channel::<u32>();
for i in 0..100_000 {
tx.send(i).unwrap();
}
println!("queued before the consumer ran: {}", rx.len());
drop(tx);
let mut received = 0;
while rx.recv().await.is_some() {
received += 1;
}
println!("received: {received}");
}queued before the consumer ran: 100000
received: 100000The fix is a bounded channel. send(...).await suspends the producer when the channel is full, so the consumer sets the pace (mpsc Channels). The program records the largest queue length the consumer observes.
use tokio::sync::mpsc;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let (tx, mut rx) = mpsc::channel::<u32>(8);
let producer = tokio::spawn(async move {
for i in 0..100_000 {
tx.send(i).await.unwrap();
}
});
let mut received = 0;
let mut max_queued = 0;
while let Some(_) = rx.recv().await {
max_queued = max_queued.max(rx.len() + 1);
received += 1;
}
producer.await.unwrap();
println!("received: {received}");
println!("largest queue seen: {max_queued}");
}received: 100000
largest queue seen: 8Memory use is bounded by the capacity (8 here), not by the number of messages.
Example 4: A forgotten .await
An async fn call returns a future and runs none of the body. Without .await, the body never executes.
async fn save(name: &str) {
println!("saved {name}");
}
#[tokio::main]
async fn main() {
save("a").await;
save("b"); // future created and dropped
save("c").await;
println!("done");
}saved a
saved c
doneb is never saved. The compiler warns about this (unused implementer of Future that must be used). Denying the lint turns the warning into an error:
#![deny(unused_must_use)]
async fn save(name: &str) {
println!("saved {name}");
}
#[tokio::main]
async fn main() {
save("b");
}error: unused implementer of `Future` that must be used
--> src/main.rs:9:5
|
9 | save("b");
| ^^^^^^^^^
|
= note: futures do nothing unless you `.await` or poll them
note: the lint level is defined here
--> src/main.rs:1:9
|
1 | #![deny(unused_must_use)]
| ^^^^^^^^^^^^^^^The fix is .await, or tokio::spawn when the work should run in the background. A spawned task runs even if its handle is dropped.
Example 5: A dropped JoinHandle hides a panic
A panic inside a spawned task is caught by the runtime and stored in the task's JoinHandle. If nothing awaits the handle, the program continues and reports success. The panic message appears only on standard error.
use std::time::Duration;
#[tokio::main]
async fn main() {
tokio::spawn(async {
let values: Vec<i32> = Vec::new();
let _first = values[0]; // panics
});
tokio::time::sleep(Duration::from_millis(50)).await;
println!("main: finished without errors");
}main: finished without errorsThe fix keeps the handle and inspects the result (Cancellation, abort, and Panics).
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
let values: Vec<i32> = Vec::new();
let _first = values[0];
});
match handle.await {
Ok(()) => println!("task finished"),
Err(error) if error.is_panic() => println!("task panicked"),
Err(error) => println!("task failed: {error}"),
}
}task panickedLong-lived background tasks that are never awaited need a supervisor that watches their handles, as in Actors, Worker Pools, and Pipelines.
Example 6: A cancel-unsafe select! branch
select! drops the futures of the branches that lose. A future that holds partial progress in local variables loses that progress when dropped. In the program below, read_pair reads two bytes with two awaits. The 10 ms tick arrives between the first and second byte, so the first byte is dropped with the future.
use std::io;
use tokio::io::{AsyncWriteExt, DuplexStream, AsyncReadExt};
use tokio::time::{interval, sleep, Duration};
async fn read_pair(reader: &mut DuplexStream) -> io::Result<(u8, u8)> {
let first = reader.read_u8().await?;
let second = reader.read_u8().await?;
Ok((first, second))
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let (mut reader, mut writer) = tokio::io::duplex(64);
tokio::spawn(async move {
sleep(Duration::from_millis(5)).await;
writer.write_all(&[1]).await.unwrap();
sleep(Duration::from_millis(10)).await;
writer.write_all(&[2, 3]).await.unwrap();
});
let mut ticker = interval(Duration::from_millis(10));
ticker.tick().await; // the first tick completes immediately
loop {
tokio::select! {
pair = read_pair(&mut reader) => match pair {
Ok(pair) => println!("pair: {pair:?}"),
Err(error) => {
println!("read ended: {:?}", error.kind());
break;
}
},
_ = ticker.tick() => println!("tick"),
}
}
}tick
pair: (2, 3)
read ended: UnexpectedEofThe writer sent the bytes 1, 2, 3. The reader saw the pair (2, 3) and never saw 1. A cancel-safe future does not lose progress when dropped (select!: Racing Futures lists which Tokio methods are cancel safe). AsyncReadExt::read_u8 is documented as cancel safe: if another branch wins, no data was read. The fix stores the partial state in a struct that outlives the future.
use std::io;
use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
use tokio::time::{interval, sleep, Duration};
struct PairReader {
first: Option<u8>,
}
impl PairReader {
async fn next(&mut self, reader: &mut DuplexStream) -> io::Result<(u8, u8)> {
if self.first.is_none() {
self.first = Some(reader.read_u8().await?);
}
let second = reader.read_u8().await?;
let first = self.first.take().expect("set above");
Ok((first, second))
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let (mut reader, mut writer) = tokio::io::duplex(64);
tokio::spawn(async move {
sleep(Duration::from_millis(5)).await;
writer.write_all(&[1]).await.unwrap();
sleep(Duration::from_millis(10)).await;
writer.write_all(&[2, 3]).await.unwrap();
});
let mut pairs = PairReader { first: None };
let mut ticker = interval(Duration::from_millis(10));
ticker.tick().await;
loop {
tokio::select! {
pair = pairs.next(&mut reader) => match pair {
Ok(pair) => println!("pair: {pair:?}"),
Err(error) => {
println!("read ended: {:?}", error.kind());
break;
}
},
_ = ticker.tick() => println!("tick"),
}
}
}tick
pair: (1, 2)
read ended: UnexpectedEofThe pair (1, 2) arrives intact. The first byte waits in PairReader while the tick branch runs.
Example 7: Inconsistent lock order
Two tasks that take the same two locks in opposite orders can each hold one lock and wait for the other. Neither ever proceeds. The sleep between the two acquisitions guarantees that both tasks hold their first lock before either asks for the second. A timeout turns the hang into a visible result.
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{sleep, timeout, Duration};
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let a = Arc::new(Mutex::new(0));
let b = Arc::new(Mutex::new(0));
let (a1, b1) = (Arc::clone(&a), Arc::clone(&b));
let first = tokio::spawn(async move {
let _guard_a = a1.lock().await;
sleep(Duration::from_millis(10)).await;
let _guard_b = b1.lock().await;
});
let (a2, b2) = (Arc::clone(&a), Arc::clone(&b));
let second = tokio::spawn(async move {
let _guard_b = b2.lock().await;
sleep(Duration::from_millis(10)).await;
let _guard_a = a2.lock().await;
});
let both = async {
first.await.unwrap();
second.await.unwrap();
};
let result = timeout(Duration::from_secs(1), both).await;
println!("finished within 1 s: {}", result.is_ok());
}finished within 1 s: falseThe fix is one global order: every task locks a before b (Shared State: Mutex and RwLock lists other deadlock patterns).
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{sleep, timeout, Duration};
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let a = Arc::new(Mutex::new(0));
let b = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..2 {
let (a, b) = (Arc::clone(&a), Arc::clone(&b));
handles.push(tokio::spawn(async move {
let _guard_a = a.lock().await;
sleep(Duration::from_millis(10)).await;
let _guard_b = b.lock().await;
}));
}
let all = async {
for handle in handles {
handle.await.unwrap();
}
};
let result = timeout(Duration::from_secs(1), all).await;
println!("finished within 1 s: {}", result.is_ok());
}finished within 1 s: trueExample 8: block_on inside async code
block_on parks the current thread until a future finishes. Called from a thread that is already driving tasks, it would stall the runtime, so Tokio panics instead.
fn load_config() -> u32 {
tokio::runtime::Handle::current().block_on(async { 42 })
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let value = load_config();
println!("config = {value}");
}thread 'main' panicked at src/main.rs:2:39:
Cannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks.Two fixes exist. When the function can be async, make it async and .await it. When a synchronous function must stay synchronous, run it on the blocking pool, where block_on is allowed.
fn load_config() -> u32 {
tokio::runtime::Handle::current().block_on(async { 42 })
}
async fn load_config_async() -> u32 {
42
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let direct = load_config_async().await;
let from_pool = tokio::task::spawn_blocking(load_config).await.unwrap();
println!("direct = {direct}, from blocking pool = {from_pool}");
}direct = 42, from blocking pool = 42Example 9: A missed Notify wakeup
Notify::notify_waiters wakes only the tasks that are waiting at that moment and stores nothing. A task that starts waiting afterward sleeps until the next notification. notify_one stores one permit when nobody waits, so a later notified().await completes immediately (Semaphore, Notify, Barrier, and OnceCell).
use std::sync::Arc;
use tokio::sync::Notify;
use tokio::time::{timeout, Duration};
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let notify = Arc::new(Notify::new());
notify.notify_waiters(); // nobody is waiting, so nothing is stored
let result = timeout(Duration::from_secs(1), notify.notified()).await;
println!("after notify_waiters: woke = {}", result.is_ok());
notify.notify_one(); // stores a permit
let result = timeout(Duration::from_secs(1), notify.notified()).await;
println!("after notify_one: woke = {}", result.is_ok());
}after notify_waiters: woke = false
after notify_one: woke = trueWhen the event can happen before the waiter starts, keep the event itself in shared state (a flag or a counter) and use Notify only to wake sleepers. The waiter checks the state first and then waits.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::Notify;
use tokio::time::{timeout, Duration};
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let ready = Arc::new(AtomicBool::new(false));
let notify = Arc::new(Notify::new());
// The event happens before anyone waits.
ready.store(true, Ordering::SeqCst);
notify.notify_waiters();
let waiter = async {
while !ready.load(Ordering::SeqCst) {
notify.notified().await;
}
};
let result = timeout(Duration::from_secs(1), waiter).await;
println!("waiter finished: {}", result.is_ok());
}waiter finished: trueThe flag survives the early notification, so the waiter never sleeps. A gap remains when the flag is set and notify_waiters runs between the flag check and the creation of the Notified future. Creating the future first and calling Notified::enable on it (pinned) before the check closes the gap, because notify_waiters then reaches the registered future. The Notify documentation shows this pattern.
Example 10: std::sync::mpsc in async code
std::sync::mpsc::Receiver::recv blocks the thread. On a current-thread runtime, the task that would send the message cannot run while the receiver blocks. This program uses recv_timeout so that it ends instead of hanging forever.
use std::sync::mpsc;
use std::time::Duration;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let (tx, rx) = mpsc::channel();
tokio::spawn(async move {
tx.send("job").unwrap();
});
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(message) => println!("received {message}"),
Err(error) => println!("recv_timeout: {error}"),
}
tokio::task::yield_now().await;
println!("after yielding: {:?}", rx.try_recv());
}recv_timeout: timed out waiting on channel
after yielding: Ok("job")The sender task was ready to run the whole time, but the blocked thread never gave it a turn. With a plain recv() the program would hang. The fix is a channel whose recv is async (mpsc Channels).
use tokio::sync::mpsc;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let (tx, mut rx) = mpsc::channel(4);
tokio::spawn(async move {
tx.send("job").await.unwrap();
});
println!("received {:?}", rx.recv().await);
}received Some("job")Use std::sync::mpsc only between plain threads, or inside spawn_blocking.
Example 11: Oversized futures
A future stores every local variable that lives across an .await. A large array on the stack becomes part of the future, and every spawned copy of the future pays for it. The example prints size comparisons instead of exact sizes, because exact sizes depend on the compiler version.
use std::mem::size_of_val;
use tokio::task::yield_now;
async fn with_array() {
let buffer = [0u8; 16 * 1024];
yield_now().await;
println!("{}", buffer.len());
}
async fn with_vec() {
let buffer = vec![0u8; 16 * 1024];
yield_now().await;
println!("{}", buffer.len());
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let array_future = with_array();
let vec_future = with_vec();
println!("array future holds the 16 KiB buffer: {}", size_of_val(&array_future) >= 16 * 1024);
println!("vec future is under 128 bytes: {}", size_of_val(&vec_future) < 128);
}array future holds the 16 KiB buffer: true
vec future is under 128 bytes: trueA Vec keeps its bytes on the heap, so the future stores only a pointer, a capacity, and a length. Box::new([0u8; N]) has the same effect for a fixed-size array. Anatomy of a Task covers task allocation and future size in detail.
Example 12: sleep in a loop instead of interval
A loop of work; sleep(period) waits for the work time plus the period, so the schedule drifts by the work time on every iteration. interval schedules ticks from a fixed start and does not drift. The paused clock makes the times exact.
use tokio::time::{interval, sleep, Duration, Instant};
async fn work() {
sleep(Duration::from_millis(3)).await;
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
for _ in 0..4 {
println!("sleep loop starts work at {} ms", start.elapsed().as_millis());
work().await;
sleep(Duration::from_millis(10)).await;
}
let start = Instant::now();
let mut ticker = interval(Duration::from_millis(10));
for _ in 0..4 {
ticker.tick().await;
println!("interval starts work at {} ms", start.elapsed().as_millis());
work().await;
}
}sleep loop starts work at 0 ms
sleep loop starts work at 13 ms
sleep loop starts work at 26 ms
sleep loop starts work at 39 ms
interval starts work at 0 ms
interval starts work at 10 ms
interval starts work at 20 ms
interval starts work at 30 msThe sleep loop starts work at 0, 13, 26, and 39 ms. The interval starts work at 0, 10, 20, and 30 ms. When the work takes longer than the period, interval applies its MissedTickBehavior (Time: sleep, timeout, and interval).
Example 13: Unbounded spawn fan-out
Spawning one task per input item is cheap for 10 items and harmful for 10 million. Each task holds its future, and each task usually holds a resource such as a socket or a file. The program counts how many tasks run at the same time.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::time::{sleep, Duration};
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let running = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..1000 {
let (running, peak) = (Arc::clone(&running), Arc::clone(&peak));
handles.push(tokio::spawn(async move {
let now = running.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(now, Ordering::SeqCst);
sleep(Duration::from_millis(10)).await;
running.fetch_sub(1, Ordering::SeqCst);
}));
}
for handle in handles {
handle.await.unwrap();
}
println!("peak concurrent tasks: {}", peak.load(Ordering::SeqCst));
}peak concurrent tasks: 1000The fix limits concurrency with a Semaphore. The loop acquires a permit before it spawns, so at most 8 tasks exist at once (Backpressure and Load Shedding).
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Semaphore;
use tokio::time::{sleep, Duration};
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let running = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
let permits = Arc::new(Semaphore::new(8));
let mut handles = Vec::new();
for _ in 0..1000 {
let permit = Arc::clone(&permits).acquire_owned().await.unwrap();
let (running, peak) = (Arc::clone(&running), Arc::clone(&peak));
handles.push(tokio::spawn(async move {
let now = running.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(now, Ordering::SeqCst);
sleep(Duration::from_millis(10)).await;
running.fetch_sub(1, Ordering::SeqCst);
drop(permit);
}));
}
for handle in handles {
handle.await.unwrap();
}
println!("peak concurrent tasks: {}", peak.load(Ordering::SeqCst));
}peak concurrent tasks: 8JoinSet with a permit per task is the structured version of the same idea (JoinSet and Structured Concurrency).
Example 14: spawn before a runtime exists
tokio::spawn needs a runtime context on the calling thread. In a plain fn main with no runtime, it panics.
fn main() {
tokio::spawn(async {
println!("never runs");
});
}thread 'main' panicked at src/main.rs:2:5:
there is no reactor running, must be called from the context of a Tokio 1.x runtimeThe fix creates a runtime first and spawns from inside it. Runtime::spawn and Runtime::block_on both provide the context (Your First Tokio Program).
fn main() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let handle = runtime.spawn(async { 21 * 2 });
let value = runtime.block_on(handle).unwrap();
println!("value = {value}");
}value = 42The same error appears when code runs on a thread that the runtime does not own, such as a std::thread::spawn thread that calls tokio::spawn. Such a thread needs a Handle cloned from the runtime, and calls handle.spawn(...).
Debugging toolbox
The pitfalls above show up as three symptoms: a hang, a slowdown, or a panic. The table lists a first check for each.
| Symptom | First check |
|---|---|
| The program hangs | Wrap the suspect future in timeout and log which step timed out; count live tasks with Handle::metrics |
| Latency spikes while CPU use is low | Look for blocking calls (std::thread::sleep, synchronous file or DNS calls, heavy loops without .await) |
| Memory grows | Look for unbounded channels, unbounded spawn, and collections that are never cleaned |
| A task disappears silently | Check that its JoinHandle is awaited and its result inspected |
| Output stops after a select loop | Check the cancel safety of every branch future |
| A panic mentions a reactor or runtime | Check that the call happens inside a runtime context |
Example 15: Counting live tasks
Handle::metrics().num_alive_tasks() returns the number of tasks that have not finished. A count that only grows during steady load points to tasks that wait for something that never happens (The Multi-Thread Scheduler and Runtime Flavors and the Builder describe the metrics API). This program starts three tasks that wait on a oneshot receiver whose sender is kept alive and never used.
use tokio::sync::oneshot;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let mut senders = Vec::new();
for _ in 0..3 {
let (tx, rx) = oneshot::channel::<()>();
senders.push(tx);
tokio::spawn(async move {
let _ = rx.await;
});
}
tokio::task::yield_now().await;
let metrics = tokio::runtime::Handle::current().metrics();
println!("alive tasks while senders exist: {}", metrics.num_alive_tasks());
senders.clear(); // dropping the senders completes the receivers
tokio::task::yield_now().await;
println!("alive tasks after the senders are dropped: {}", metrics.num_alive_tasks());
}alive tasks while senders exist: 3
alive tasks after the senders are dropped: 0The metrics handle reads the counter without stopping the runtime, so it can run in a periodic reporting task. Two other tools cover what the count cannot show. tracing spans record where each task is and how long each step takes (Tracing and Observability). tokio-console lists live tasks with their poll counts and idle times.
Example 16: A watchdog that names the stuck step
A helper that wraps a future in timeout and reports the step name turns a silent hang into a message.
use std::future::Future;
use tokio::time::{sleep, timeout, Duration};
async fn watchdog<F: Future>(step: &str, limit: Duration, future: F) -> Option<F::Output> {
match timeout(limit, future).await {
Ok(output) => Some(output),
Err(_) => {
println!("stuck: {step} did not finish within {} ms", limit.as_millis());
None
}
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let connect = watchdog("connect", Duration::from_millis(100), async {
sleep(Duration::from_millis(20)).await;
"connected"
})
.await;
println!("connect: {connect:?}");
let handshake = watchdog("handshake", Duration::from_millis(100), async {
std::future::pending::<()>().await;
})
.await;
println!("handshake: {handshake:?}");
}connect: Some("connected")
stuck: handshake did not finish within 100 ms
handshake: NoneTimeouts on every external call also serve as production limits, not only as debugging aids (Error Handling in Async Code).
Interview points
- Blocking a worker delays every task on that worker. On a current-thread runtime that means every task.
- A
stdmutex guard held across.awaitmakes the future!Send; the compiler rejectstokio::spawnof it. select!drops losing futures. A branch future must be cancel safe, or its state must live outside the future.- An unbounded channel or unbounded
spawnhas no backpressure, so memory grows with the input rate. - A spawned task's panic is delivered only through its
JoinHandle. notify_waitersstores no permit;notify_onestores one.block_oninside a runtime panics; use.awaitorspawn_blocking.tokio::spawnpanics outside a runtime context;Handle::spawnworks from any thread.- Futures store the locals that live across
.await, so large stack arrays enlarge every task. intervalkeeps a fixed schedule;sleepin a loop adds the work time to every period.
Summary
- Blocking calls belong in
spawn_blocking; async code usestokio::time::sleep,tokio::sync, andtokio::fs. - Scope
stdguards so they end before any.await, or usetokio::sync::Mutex. - Bound every queue and every fan-out: bounded
mpsc,Semaphore,JoinSetwith a limit. - Await futures, and await or supervise
JoinHandles. - Keep partial progress outside futures that
select!can cancel. - Acquire locks in one global order.
- Use
notify_oneor shared state withNotifywhen the event can precede the waiter. - Use
intervalfor periodic work. - Debug hangs with timeouts, task counts, tracing, and
tokio-console.
