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::Mutexwhenever the critical section is fast (updating aHashMapor counter in memory) and theMutexGuardis dropped before the next.awaitpoint.std::sync::Mutexis significantly faster than an async mutex. - Use an async
Mutexonly when you genuinely need to hold the lock guard across an.awaitpoint (such as serializing multi-step frames over a shared async connection).