Guide contents

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.

Rust
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();
}
Output
blocking call finished
timer fired

The 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).

Rust
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();
}
Output
timer fired
blocking call finished

The 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).

Rust✗ does not compile
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();
}
Output
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).

Rust
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());
}
Output
value = 1

tokio::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.

Rust
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}");
}
Output
queued before the consumer ran: 100000
received: 100000

The 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.

Rust
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}");
}
Output
received: 100000
largest queue seen: 8

Memory 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.

Rust
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");
}
Output
saved a
saved c
done

b 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:

Rust✗ does not compile
#![deny(unused_must_use)]

async fn save(name: &str) {
    println!("saved {name}");
}

#[tokio::main]
async fn main() {
    save("b");
}
Output
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.

Rust
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");
}
Output
main: finished without errors

The fix keeps the handle and inspects the result (Cancellation, abort, and Panics).

Rust
#[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}"),
    }
}
Output
task panicked

Long-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.

Rust
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"),
        }
    }
}
Output
tick
pair: (2, 3)
read ended: UnexpectedEof

The 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.

Rust
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"),
        }
    }
}
Output
tick
pair: (1, 2)
read ended: UnexpectedEof

The 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.

Rust
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());
}
Output
finished within 1 s: false

The fix is one global order: every task locks a before b (Shared State: Mutex and RwLock lists other deadlock patterns).

Rust
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());
}
Output
finished within 1 s: true

Example 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.

Rustpanics at runtime
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}");
}
Output
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.

Rust
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}");
}
Output
direct = 42, from blocking pool = 42

Example 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).

Rust
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());
}
Output
after notify_waiters: woke = false
after notify_one: woke = true

When 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.

Rust
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());
}
Output
waiter finished: true

The 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.

Rust
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());
}
Output
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).

Rust
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);
}
Output
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.

Rust
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);
}
Output
array future holds the 16 KiB buffer: true
vec future is under 128 bytes: true

A 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.

Rust
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;
    }
}
Output
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 ms

The 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.

Rust
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));
}
Output
peak concurrent tasks: 1000

The 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).

Rust
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));
}
Output
peak concurrent tasks: 8

JoinSet 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.

Rustpanics at runtime
fn main() {
    tokio::spawn(async {
        println!("never runs");
    });
}
Output
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 runtime

The fix creates a runtime first and spawns from inside it. Runtime::spawn and Runtime::block_on both provide the context (Your First Tokio Program).

Rust
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}");
}
Output
value = 42

The 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.

Rust
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());
}
Output
alive tasks while senders exist: 3
alive tasks after the senders are dropped: 0

The 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.

Rust
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:?}");
}
Output
connect: Some("connected")
stuck: handshake did not finish within 100 ms
handshake: None

Timeouts 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 std mutex guard held across .await makes the future !Send; the compiler rejects tokio::spawn of 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 spawn has no backpressure, so memory grows with the input rate.
  • A spawned task's panic is delivered only through its JoinHandle.
  • notify_waiters stores no permit; notify_one stores one.
  • block_on inside a runtime panics; use .await or spawn_blocking.
  • tokio::spawn panics outside a runtime context; Handle::spawn works from any thread.
  • Futures store the locals that live across .await, so large stack arrays enlarge every task.
  • interval keeps a fixed schedule; sleep in a loop adds the work time to every period.

Summary

  • Blocking calls belong in spawn_blocking; async code uses tokio::time::sleep, tokio::sync, and tokio::fs.
  • Scope std guards so they end before any .await, or use tokio::sync::Mutex.
  • Bound every queue and every fan-out: bounded mpsc, Semaphore, JoinSet with 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_one or shared state with Notify when the event can precede the waiter.
  • Use interval for periodic work.
  • Debug hangs with timeouts, task counts, tracing, and tokio-console.