Step 7 of 7
Async vs threads, spawn_blocking, and locks across .await
When to use which
Async isn't "faster threads". It makes waiting cheap. A task costs a few hundred bytes, and switching tasks at an .await is far cheaper than the OS switching threads. It does nothing to make CPU work faster.
| Your program mostly... | Use |
|---|---|
| waits on the network or disk, for many things at once (web servers, API clients, crawlers, chat) | async with Tokio |
| computes: parsing big files, image processing, number crunching | threads: rayon, or thread::scope |
| does a bit of both | async, sending the heavy parts to spawn_blocking or rayon |
| is a small CLI tool doing one thing after another | plain synchronous code; you may not need either |
Async has costs: a runtime dependency, async spreading up your call chain, and harder-to-read compiler errors. Don't reach for it just because a program does some I/O.
Don't block the runtime
Tokio runs many tasks on a few worker threads, and switches between them only at .await points. A task that runs a long CPU loop, or calls a blocking function (std::fs::read, std::thread::sleep, a synchronous database driver), holds its worker thread the whole time, and every other task waiting for that worker stalls. A good rule of thumb: never go more than about 100 microseconds between two .awaits.
For blocking work, tokio::task::spawn_blocking(closure) runs a normal (non-async) closure on a separate pool of threads made for blocking, and gives you a JoinHandle to .await:
fn checksum(data: &[u8]) -> u64 {
// imagine this takes 200 ms of pure CPU work
data.iter().map(|&b| u64::from(b)).sum()
}
#[tokio::main]
async fn main() {
let data: Vec<u8> = (0..=255).cycle().take(10_000).collect();
let handle = tokio::task::spawn_blocking(move || checksum(&data));
// while it runs, this worker thread is free for other tasks
let sum = handle.await.unwrap();
println!("checksum: {sum}");
}
checksum: 1273080
The closure must own its data (move), like a spawned thread. For heavy parallel CPU work, spawn_blocking a closure that uses rayon, so the computation spreads over all cores without touching the async workers.
std Mutex and .await
A std::sync::Mutex is fine in async code, as long as you never hold its guard across an .await. A task that pauses while holding the lock keeps every other task that needs the lock waiting, possibly on the very worker thread the lock holder needs to resume on. The compiler catches the most common case: a std MutexGuard isn't Send, so a spawned task that holds one across an .await doesn't compile:
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[tokio::main]
async fn main() {
let hits = Arc::new(Mutex::new(0));
let for_task = Arc::clone(&hits);
let handle = tokio::spawn(async move {
let mut count = for_task.lock().unwrap();
*count += 1;
tokio::time::sleep(Duration::from_millis(1)).await;
*count += 1;
});
handle.await.unwrap();
println!("{}", hits.lock().unwrap());
}
error: future cannot be sent between threads safely
The fix is to keep the guard in a block that ends before the .await:
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[tokio::main]
async fn main() {
let hits = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let hits = Arc::clone(&hits);
handles.push(tokio::spawn(async move {
{
let mut count = hits.lock().unwrap();
*count += 1;
} // the guard is dropped here, before the .await
tokio::time::sleep(Duration::from_millis(1)).await;
}));
}
for handle in handles {
handle.await.unwrap();
}
println!("hits: {}", hits.lock().unwrap());
}
hits: 4
If you really must keep data locked across an .await (for example, to keep writes to one connection in order), use tokio::sync::Mutex: its lock().await waits without blocking the thread, and its guard may be held across .await. It's slower than the std one, so use it only when you need that. Clippy's await_holding_lock lint warns about std guards held across .await.
Common mistakes
- CPU-heavy loops or blocking calls in async functions: everything else on that worker freezes. Use
spawn_blocking(or rayon inside it). - Holding a
stdMutexGuardacross.await: put the guard in a block, or switch totokio::sync::Mutexif you truly need it held. - Using async for CPU-bound work: it adds complexity and no speed. Use threads.
Your task
slow_hash is expensive CPU work, so it must not run on the async workers. Write async fn hash_all(passwords: Vec<String>) -> Vec<u64> that starts one tokio::task::spawn_blocking job per password (all of them before awaiting any), then awaits the handles in order and returns the hashes in the same order as passwords.
Challenge 2
record_visit holds a std MutexGuard across the .await, so tokio::spawn rejects it ("future cannot be sent between threads safely"). Fix record_visit so the guard is dropped before the .await (lock again afterwards for the second push). The program should print:
saved /cart
saved /home
visit /cart
visit /home
Challenge 3
Tickets must be numbered in the order they're saved, so the lock has to stay held while the (async) save runs. That's the job for tokio::sync::Mutex. Write async fn issue_ticket(counter: &tokio::sync::Mutex<u32>) -> u32: lock with counter.lock().await, add 1 to the counter, call save_ticket(number).await while still holding the lock, and return the new number.