Rust Blocking the Executor
Blocking is work that holds an executor thread without reaching an await point. A blocking call holds the whole thread, so every task on it waits.
The trap: a sleep that freezes the thread
std::thread::sleep knows nothing about tasks. It puts the thread itself to sleep:
async fn sleeper(name: &str) {
std::thread::sleep(Duration::from_millis(30)); // blocks!
println!("{name} done");
}Blocking jobs queue up
Run two of them "together" with join!. They still run one after another โ 30 ms plus 30 ms:
tokio::join!(sleeper("first"), sleeper("second"));The async twin parks the task
tokio::time::sleep pauses only the task. Now the short wait finishes first, and both together take about 30 ms:
async fn sleeper(name: &str, ms: u64) {
tokio::time::sleep(Duration::from_millis(ms)).await;
println!("{name} done");
}
tokio::join!(sleeper("slow", 30), sleeper("fast", 10));No async twin? Use a spare thread
spawn_blocking runs blocking work on a pool built for it, and hands back a future for the result:
let job = tokio::task::spawn_blocking(|| {
std::thread::sleep(Duration::from_millis(30));
"blocking job finished"
});
println!("{}", job.await.unwrap());Example
// Cargo.toml: tokio = { version = "1", features = ["full"] }
use tokio::time::{sleep, Duration};
async fn sleeper(name: &str, ms: u64) {
sleep(Duration::from_millis(ms)).await;
println!("{name} sleeper done");
}
#[tokio::main]
async fn main() {
tokio::join!(sleeper("slow", 30), sleeper("fast", 10));
}Key points
- An executor thread carries many tasks.
- It switches between them at await points.
- A task that never awaits keeps the whole thread.
- The same trap hides in blocking file and network functions from ordinary non-async libraries.
- It also hides in long stretches of heavy arithmetic.
- Holding a std Mutex across an .await has the same smell: other tasks may queue behind a lock held by a task that is itself waiting
- Tasks are cheap, and many share one thread.
- Blocking is the most common way beginners make async code slow.
In this example, join! starts two sleepers together: slow waits 30 milliseconds, fast waits 10. Because both waits are async sleeps, the executor thread is free between them. fast reaches its finish first and prints first. The whole program takes about 30 milliseconds. That is the longer wait, not 40. Now picture sleeper using std::thread::sleep instead. slow would seize the thread for its full 30 milliseconds before fast even starts. The lines would come out in the order slow, fast. Same code shape, completely different behaviour โ that difference is this pitfall.
Note
A task is not a thread, so blocking a thread silently blocks every task that thread was carrying.
Try it Yourself
Edit the code, then press Run.
Running is simulated ยท Rust Playground
Exercise
You must call a blocking library function from inside async code. What is the safe way?