Asynchronous Rust

Blocking Operations in Async Contexts

The cardinal rule of asynchronous Rust is: never block the executor thread inside an async function. Because an async runtime multiplexes hundreds or thousands of concurrent tasks onto a small, fixed pool of OS worker threads (typically equal to the number of CPU cores), blocking an OS worker thread stalls every other task scheduled on that thread.

Operations That Must Not Be Called Directly on an Async Worker Thread

Blocking Category Examples to Avoid Directly in async fn Async / Nonblocking Replacement
Thread sleep std::thread::sleep(dur) Async runtime timer (tokio::time::sleep(dur).await)
Synchronous network / file I/O std::net::TcpStream::read, std::fs::read_to_string, blocking HTTP/DB drivers Async networking (tokio::net::TcpStream), or offload disk/SQLite I/O via spawn_blocking
Long-held synchronous locks Locking std::sync::Mutex across an .await or for milliseconds of contention Short non-await critical sections with std::sync::Mutex, or async tokio::sync::Mutex when lock must be held across .await
Heavy CPU-bound computation Password hashing (argon2/bcrypt), compression (zstd), image/video encoding, large sorting Offload to spawn_blocking or a rayon thread pool and await a one-shot reply channel
What Happens When 1 Task Calls `std::thread::sleep(5s)` on an Async Worker Thread:

  Worker Thread 0 Queue: [ Task A (calls thread::sleep!) | Task B | Task C | Task D ]
                                 |
                                 v
  Worker Thread 0 is parked by the OS kernel for 5 full seconds!
  Tasks B, C, and D cannot poll until Task A wakes up (or another worker steals them)!

Offloading Blocking or CPU-Intensive Work to a Dedicated Blocking Thread Pool

When an async workflow must execute a synchronous blocking API (such as a synchronous C FFI library, SQLite query, or heavy CPU cryptographic hash), offload the closure to a dedicated blocking thread pool and .await the completion future so the async executor thread stays 100% responsive:

use std::future::Future;
use std::pin::{Pin, pin};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use std::thread;

// A Future that offloads a blocking closure `F` onto a dedicated background thread
// and wakes the async executor's `Waker` as soon as the result `T` is ready:
pub struct SpawnBlocking<T> {
    shared: Arc<Mutex<BlockingState<T>>>,
}

struct BlockingState<T> {
    result: Option<T>,
    waker: Option<Waker>,
}

pub fn spawn_blocking<F, T>(f: F) -> SpawnBlocking<T>
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,
{
    let shared = Arc::new(Mutex::new(BlockingState {
        result: None,
        waker: None,
    }));

    let worker_shared = Arc::clone(&shared);
    thread::spawn(move || {
        let output = f();
        let mut state = worker_shared.lock().unwrap();
        state.result = Some(output);
        if let Some(waker) = state.waker.take() {
            drop(state);
            waker.wake();
        }
    });

    SpawnBlocking { shared }
}

impl<T> Future for SpawnBlocking<T> {
    type Output = T;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let mut state = self.shared.lock().unwrap();
        if let Some(res) = state.result.take() {
            Poll::Ready(res)
        } else {
            state.waker = Some(cx.waker().clone());
            Poll::Pending
        }
    }
}

fn main() {
    let blocking_fut = spawn_blocking(|| {
        // Heavy CPU or blocking OS work runs off the async executor thread:
        (1..=1000_u64).sum::<u64>()
    });

    let mut pinned = pin!(blocking_fut);
    let mut cx = Context::from_waker(Waker::noop());
    loop {
        if let Poll::Ready(sum) = pinned.as_mut().poll(&mut cx) {
            println!("offloaded blocking task completed with sum = {}", sum);
            break;
        }
        thread::yield_now();
    }
}
offloaded blocking task completed with sum = 500500

When std::sync::Mutex IS Preferred Over an Async Mutex Inside async fn

Contrary to a common misconception, you do not need an async mutex (tokio::sync::Mutex) just because a lock is accessed inside an async fn!

  • Use std::sync::Mutex whenever the critical section is fast (updating a HashMap or counter in memory) and the MutexGuard is dropped before the next .await point. std::sync::Mutex is significantly faster than an async mutex.
  • Use an async Mutex only when you genuinely need to hold the lock guard across an .await point (such as serializing multi-step frames over a shared async connection).