Tokio runtime current_thread, multi_thread, and spawn_blocking
Count the threads that poll your futures, and keep blocking calls off them.
A future does nothing until a runtime polls it. Know which threads you gave the runtime, and keep blocking calls off them.
§I. Frame
TRPL ch17 says it plainly: futures are lazy, and something has to drive them. In a Rust service that something is almost always Tokio. Every later Duha row (Axum, Loco, the h3s CRUD) runs on top of the runtime you pick here, so this lesson measures what the runtime does with your tasks instead of describing it.
Done bar: you can say which flavor #[tokio::main] builds, predict the elapsed time of eight 50 ms tasks under three kinds of work, and move a blocking call to the right pool.
§II. Three techniques
Count the Drivers (named technique). Before reading any async code, count the threads that poll futures.
| Builder | Drivers | #[tokio::main] form |
|---|---|---|
Builder::new_current_thread() | the one thread that calls block_on | #[tokio::main(flavor = "current_thread")] |
Builder::new_multi_thread() | a worker pool, one per CPU core by default | #[tokio::main] (the default) |
Drivers also need resources switched on. enable_time() turns on the timer that tokio::time::sleep uses; enable_io() turns on the socket reactor; enable_all() does both. A runtime built without the timer panics the first time a task sleeps.
Await Means Yield (named technique). An .await on a pending future hands the thread back to the runtime so another task can run. std::thread::sleep, a synchronous file read, or a CPU loop has no .await, so the thread stays held. On a one-driver runtime that stops every other task.
Blocking Goes to the Pool (named technique). tokio::task::spawn_blocking runs a closure on a separate pool of threads meant for blocking work (up to 512 by default, per the docs) and gives you a future for the result. The drivers stay free.
§III. Measure it
crate/src/lib.rs spawns N tasks into a JoinSet and waits for all of them. Each task spends 50 ms one of three ways: tokio::time::sleep (yields), std::thread::sleep inside the task (holds the driver), or std::thread::sleep inside spawn_blocking.
let mut set = JoinSet::new();
for i in 0..n {
set.spawn(async move {
match work {
Work::AsyncSleep => tokio::time::sleep(Duration::from_millis(ms)).await,
Work::BlockingInTask => std::thread::sleep(Duration::from_millis(ms)),
Work::SpawnBlocking => tokio::task::spawn_blocking(move ||
std::thread::sleep(Duration::from_millis(ms))).await.expect("blocking task"),
}
i
});
}
while let Some(res) = set.join_next().await { res.expect("task panicked"); }
cargo run on the lab Mac (real output):
8 tasks x 50 ms each
current_thread AsyncSleep 61 ms
current_thread BlockingInTask 453 ms
current_thread SpawnBlocking 57 ms
multi_thread(4) AsyncSleep 52 ms
multi_thread(4) BlockingInTask 116 ms
multi_thread(4) SpawnBlocking 60 ms
Read the table row by row. One driver with async sleeps finishes in about one sleep, because all eight wait together. One driver with blocking sleeps takes eight sleeps: 453 ms. Four drivers cut that to roughly two sleeps, which is why a blocking bug often hides on a laptop with many cores and shows up in a one-core container. spawn_blocking brings both flavors back to about one sleep.
cargo test: 4 passed, 0 warnings. The tests assert those shapes with bounds (under 100 ms, at least 400 ms, and so on).
§IV. The smallest Duha API
crate/src/bin/health.rs is a plain tokio::net::TcpListener loop on a current_thread runtime that answers every request with {"status":"ok"}. Each connection gets its own tokio::spawn, so a slow client does not stall the accept loop. Real run:
listening on 127.0.0.1:8787
HTTP/1.1 200 OK
content-type: application/json
access-control-allow-origin: *
content-length: 15
connection: close
{"status":"ok"}
No framework yet; Axum is the next A-day row. Asr fetches this endpoint, and Ops packages this binary with Nix on 2026-10-12.
§V. Drills
- A handler calls
std::fs::read_to_stringon a 2 GB file under#[tokio::main(flavor = "current_thread")]. What happens to the other requests, and what are the two fixes? (They stall; usetokio::fsorspawn_blocking.) tokio::time::sleeppanics with a message about the timer. Which builder call is missing? (enable_time()orenable_all().)- Predict
multi_thread(2)with eight blocking 50 ms tasks. (About 200 ms: four rounds of two.) - Why can a test that asserts "under 100 ms" pass here and fail in CI? (Wall-clock upper bounds depend on the host; see Ops 2026-10-12, where it did fail.)
Close
Count the Drivers, Await Means Yield, Blocking Goes to the Pool. Pick the flavor on purpose, give the timer and IO drivers what they need, and send every blocking call to spawn_blocking. Then build Axum on top.
Related
- Tome: TRPL ch17.1, ch17.6 (grounded-in)
- Web: tokio::runtime · spawn_blocking · JoinSet · Tokio tutorial
- Next A-day: Axum Router, extractors, State