C.W.K.
Stream
Lesson 05 of 05 · published

Async vs Threads — Choosing

~11 min · async, threads, decision, spawn-blocking

Level 0Rust Curious
0 XP0/80 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete

The most common async mistake is reaching for it when threads would be simpler — or vice versa. This lesson is the decision framework: when async, when threads, and how to tell.

The core distinction

I/O-bound work (waiting on network, disk, timers) suits async: tasks spend most of their time waiting, so one thread can juggle thousands by switching at each await. CPU-bound work (number crunching, parsing, compression) suits threads: there's no waiting to yield on, so you want real parallelism across cores.

The trap: blocking in async

A CPU-heavy or blocking operation inside an async task is a footgun: it doesn't yield, so it starves every other task on that thread. If you must do blocking work in an async context, hand it to tokio::task::spawn_blocking, which runs it on a dedicated thread pool so the async tasks keep flowing. Mixing the two models carelessly is the number-one async performance bug.

Match the model to the workload, and don't block the async executor. I/O-bound and high-concurrency -> async. CPU-bound and parallel -> threads. Blocking work inside async -> spawn_blocking. The wrong pairing doesn't crash — it quietly destroys throughput, which is harder to debug than a crash.

You can use both

Real systems combine them: an async web server (tokio) that offloads a CPU-heavy image resize to spawn_blocking or a thread pool. The models aren't rivals — they're tools for different shapes of work, and a mature Rust service uses each where it fits. Knowing which is which is the mark of someone who actually understands concurrency, not just the syntax.

Code

Async for I/O, spawn_blocking for CPU work·rust
#[tokio::main]
async fn main() {
    // I/O-bound: async shines (many concurrent waits)
    let io = tokio::spawn(async { fetch().await });

    // CPU-bound: offload to a blocking thread so it doesn't starve the executor
    let cpu = tokio::task::spawn_blocking(|| heavy_compute());

    println!("{} {}", io.await.unwrap(), cpu.await.unwrap());
}

async fn fetch() -> i32 { 1 }   // pretend: awaits the network
fn heavy_compute() -> i32 {      // pure CPU: belongs on a thread
    (1..=1000).sum()
}

External links

Exercise

Classify these for an async server and decide the right tool: serving HTTP requests, hashing passwords with bcrypt (CPU-heavy), reading config from disk once at startup. For each, say: plain async, spawn_blocking, or a regular thread — and why.
Hint
HTTP serving is I/O-bound -> plain async. bcrypt is CPU-heavy and would stall the executor -> spawn_blocking. A one-time startup read can be plain sync code before the runtime even starts, or a single async read — it's not on the hot path either way.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.