Async Rust with Tokio Part 8: Common Pitfalls – Blocking, Locks, and CPU-Bound Work

Async Rust with Tokio Part 8: Common Pitfalls – Blocking, Locks, and CPU-Bound Work

Most async Rust performance problems come from a small set of mistakes. None of them are subtle – they all follow directly from how the scheduler works. This post covers each one, explains why it breaks performance, and shows the correct pattern.

Pitfall 1: Blocking in an Async Context

This is the most damaging mistake. Tokio’s worker threads run async tasks. If a task blocks a thread – by calling a synchronous blocking function – that thread cannot run any other tasks. On a runtime with four worker threads, four simultaneous blocking calls stall the entire runtime.

// WRONG: blocks the worker thread
async fn bad_handler() -> String {
    let result = std::fs::read_to_string("/etc/hosts").unwrap(); // BLOCKS
    result
}

// WRONG: CPU-heavy work with no await points
async fn bad_compute(data: Vec) -> u64 {
    data.iter().map(|&x| expensive_calculation(x) as u64).sum() // may take seconds
}

// CORRECT: use spawn_blocking for blocking I/O and CPU-bound work
async fn good_handler() -> String {
    tokio::task::spawn_blocking(|| {
        std::fs::read_to_string("/etc/hosts").unwrap()
    })
    .await
    .unwrap()
}

async fn good_compute(data: Vec) -> u64 {
    tokio::task::spawn_blocking(move || {
        data.iter().map(|&x| expensive_calculation(x) as u64).sum()
    })
    .await
    .unwrap()
}

// CORRECT: use tokio::fs for async file operations
async fn async_file_read() -> String {
    tokio::fs::read_to_string("/etc/hosts").await.unwrap()
    // Note: tokio::fs internally uses spawn_blocking on most platforms
    // It is not truly async I/O, but it does not block the worker thread
}

Tokio will warn you about blocking operations that take too long. Enable the tokio_unstable feature and use tokio-console to detect blocking tasks. Any synchronous operation that takes more than a few milliseconds should move to spawn_blocking.

Pitfall 2: Holding a Mutex Across an Await Point

use std::sync::Mutex;

// WRONG: holding std::sync::Mutex across .await
// This future will not compile on multi-threaded runtime because
// MutexGuard is not Send - and even on single-threaded, it deadlocks
async fn bad_mutex_use(data: Arc>>) {
    let mut lock = data.lock().unwrap(); // std::sync::MutexGuard
    fetch_more_data().await;             // HELD across await - deadlock risk
    lock.push(String::from("new item"));
}

// CORRECT option 1: use tokio::sync::Mutex (async-aware)
use tokio::sync::Mutex as AsyncMutex;

async fn good_tokio_mutex(data: Arc>>) {
    let mut lock = data.lock().await;   // async lock - suspends, does not block thread
    // Do NOT hold across expensive awaits - just for the critical section
    lock.push(String::from("new item"));
    // lock dropped here
    
    // Now do the async work without holding the lock
    fetch_more_data().await;
}

// CORRECT option 2: minimize lock scope with std::sync::Mutex
async fn good_std_mutex(data: Arc>>) {
    // Read what we need under the lock, then release before any await
    let snapshot = {
        let lock = data.lock().unwrap();
        lock.clone() // take a snapshot
    }; // lock dropped here

    let new_data = fetch_more_data().await; // await outside lock

    {
        let mut lock = data.lock().unwrap();
        lock.push(new_data);
    } // lock dropped here
}

// Rule of thumb: prefer std::sync::Mutex with short critical sections
// Use tokio::sync::Mutex only when the critical section itself needs to await

Pitfall 3: Spawning Without Backpressure

// WRONG: spawns one task per item with no limit
async fn unbounded_spawn(items: Vec) {
    for item in items {
        tokio::spawn(process(item)); // 1,000,000 items = 1,000,000 tasks
    }
    // Memory exhaustion, task scheduler overload
}

// CORRECT: limit concurrency with a semaphore
use std::sync::Arc;
use tokio::sync::Semaphore;

async fn bounded_spawn(items: Vec) {
    let sem = Arc::new(Semaphore::new(100)); // max 100 concurrent tasks
    let mut handles = vec![];

    for item in items {
        let permit = sem.clone().acquire_owned().await.unwrap();
        handles.push(tokio::spawn(async move {
            let _permit = permit; // released when task finishes
            process(item).await
        }));
    }

    for h in handles { let _ = h.await; }
}

// ALSO CORRECT: use JoinSet with a concurrency limit
use tokio::task::JoinSet;

async fn joinset_bounded(items: Vec) {
    let mut set = JoinSet::new();

    for item in items {
        // Wait if at capacity before spawning more
        if set.len() >= 100 {
            set.join_next().await;
        }
        set.spawn(process(item));
    }

    while let Some(_) = set.join_next().await {}
}

Pitfall 4: Forgetting That .await Does Not Mean Concurrent

// WRONG: sequential, not concurrent - takes 300ms
async fn sequential() {
    fetch_a().await; // 100ms
    fetch_b().await; // 100ms
    fetch_c().await; // 100ms
}

// CORRECT: concurrent - takes ~100ms
async fn concurrent() {
    tokio::join!(
        fetch_a(),
        fetch_b(),
        fetch_c(),
    );
}

// For dynamic number of futures:
async fn concurrent_dynamic(urls: Vec) {
    let futures: Vec<_> = urls.iter().map(|url| fetch(url)).collect();
    let results = futures::future::join_all(futures).await;
    // Or use JoinSet for better error handling
}

Pitfall 5: CPU-Bound Work Starving the Scheduler

flowchart TD
    A[Worker Thread] --> B[Poll Task A]
    B --> C{Task A has\nawait point?}
    C -->|Yes| D[Task A suspends\nThread available]
    D --> E[Poll Task B]
    C -->|No - CPU loop| F[Thread blocked\nfor seconds]
    F --> G[Task B, C, D starved\nLatency spikes]
// WRONG: long CPU loop in async context
async fn bad_cpu_work(data: &[u32]) -> u64 {
    // This holds the thread for the entire duration
    data.iter().map(|&x| heavy_math(x) as u64).sum()
}

// CORRECT option 1: spawn_blocking
async fn good_spawn_blocking(data: Vec) -> u64 {
    tokio::task::spawn_blocking(move || {
        data.iter().map(|&x| heavy_math(x) as u64).sum()
    })
    .await
    .unwrap()
}

// CORRECT option 2: yield periodically for moderate work
async fn good_yield(data: &[u32]) -> u64 {
    let mut sum = 0u64;
    for (i, &x) in data.iter().enumerate() {
        sum += heavy_math(x) as u64;
        if i % 1000 == 0 {
            tokio::task::yield_now().await; // give other tasks a turn
        }
    }
    sum
}

// CORRECT option 3: rayon for data-parallel CPU work
async fn good_rayon(data: Vec) -> u64 {
    tokio::task::spawn_blocking(move || {
        use rayon::prelude::*;
        data.par_iter().map(|&x| heavy_math(x) as u64).sum()
    })
    .await
    .unwrap()
}

Pitfall 6: Not Handling Slow Receivers in Channels

// WRONG: producer loops without checking receiver health
async fn bad_producer(tx: mpsc::Sender) {
    loop {
        let data = generate_data().await;
        tx.send(data).await.unwrap(); // panics when receiver drops
    }
}

// CORRECT: handle send errors gracefully
async fn good_producer(tx: mpsc::Sender) {
    loop {
        let data = generate_data().await;
        match tx.send(data).await {
            Ok(()) => {}
            Err(_) => {
                tracing::info!("Receiver dropped, stopping producer");
                break;
            }
        }
    }
}

// CORRECT: use try_send for non-blocking send with backpressure detection
async fn producer_with_backpressure(tx: mpsc::Sender) {
    loop {
        let data = generate_data().await;
        match tx.try_send(data) {
            Ok(()) => {}
            Err(mpsc::error::TrySendError::Full(_)) => {
                tracing::warn!("Channel full - consumer is slow");
                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
            }
            Err(mpsc::error::TrySendError::Closed(_)) => break,
        }
    }
}

Quick Reference: Pitfalls and Fixes

// 1. std::thread::sleep in async -> tokio::time::sleep
// 2. std::fs::read in async -> tokio::fs::read or spawn_blocking
// 3. std::sync::Mutex held across await -> shorten scope or use tokio::sync::Mutex
// 4. Unbounded task spawn -> Semaphore or JoinSet with limit
// 5. Sequential awaits when concurrent is needed -> tokio::join! or JoinSet
// 6. CPU loop without yield -> spawn_blocking or yield_now every N iterations
// 7. unwrap() on channel send -> handle Err for closed receiver

What Comes Next

Part 9 covers observability – how to see what your async service is actually doing. tokio-console gives you a live view of task state, tracing gives you structured logs and spans, and diagnosing runtime starvation requires knowing what to measure.

References

Written by:

651 Posts

View All Posts
Follow Me :
How to whitelist website on AdBlocker?

How to whitelist website on AdBlocker?

  1. 1 Click on the AdBlock Plus icon on the top right corner of your browser
  2. 2 Click on "Enabled on this site" from the AdBlock Plus option
  3. 3 Refresh the page and start browsing the site