Hedronite · Backend Dev · Phase A · Sun 2026-10-11 · BACKFILL

Tokio runtime current_thread, multi_thread, and spawn_blocking

Count the threads that poll your futures, and keep blocking calls off them.

Lesson Class: Duha (T3 · phase A Foundations)
Cursor: A row 1: Tokio runtime
Lab: crate/ · cargo test 4 passed
Next A-day: Axum Router, extractors, State
Count the Drivers
current_thread is one; multi_thread is a pool.
Await Means Yield
No .await, no yield.
Blocking Goes to the Pool
spawn_blocking keeps drivers free.
One driver, one blocking sleep, eight waiting tasks.

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.

BuilderDrivers#[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

  1. A handler calls std::fs::read_to_string on a 2 GB file under #[tokio::main(flavor = "current_thread")]. What happens to the other requests, and what are the two fixes? (They stall; use tokio::fs or spawn_blocking.)
  2. tokio::time::sleep panics with a message about the timer. Which builder call is missing? (enable_time() or enable_all().)
  3. Predict multi_thread(2) with eight blocking 50 ms tasks. (About 200 ms: four rounds of two.)
  4. 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