RustArena

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

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.

Previous: select!: the first one to finish wins