Threads and scoped threads

Rust · Advanced · 14 min

Threads are the foundation of parallelism in Rust, and the type system is what makes them free of data races.

std::thread::spawn starts an OS thread running a closure and returns a JoinHandle<T>. Calling join() waits for it and returns Result<T, _>: Ok with the closure's return value, or Err if the thread panicked. A panic in a spawned thread does not crash the main thread by itself.

The closure must be Send + 'static. 'static because the new thread could outlive the function that spawned it, so it cannot borrow the caller's locals; Send because its captured values move to another thread. Hence the usual pattern: a move closure that takes ownership of what it needs, cloning or wrapping in Arc anything shared.

Since Rust 1.63, scoped threads remove the 'static requirement when threads finish before the function returns:

std::thread::scope(|s| {
    s.spawn(|| work(&data[..half]));
    s.spawn(|| work(&data[half..]));
}); // all scoped threads are joined here

The scope guarantees every thread is joined before it ends, so borrowing local data is safe, and the usual borrow rules still prevent two threads from mutating the same thing.

This is where Rust's slogan "fearless concurrency" comes from: sharing data between threads without synchronisation is a compile error, not a heisenbug. Data races are impossible in safe Rust, although deadlocks and logic races still are.

Practical notes:

  • std::thread::available_parallelism() tells you how many threads make sense.
  • OS threads are relatively heavy (each has its own stack, by default 2 MiB for spawned threads). For thousands of concurrent I/O tasks use async; for CPU-bound data parallelism, the rayon crate turns iter() into par_iter() with a work-stealing pool.
  • thread::Builder sets names and stack sizes; thread::sleep pauses the current thread.

Example

use std::thread;

fn main() {
    // spawn: the closure must own its data ('static)
    let name = String::from("worker");
    let handle = thread::spawn(move || {
        format!("{name} computed {}", (1..=10u64).product::<u64>())
    });
    println!("{}", handle.join().unwrap()); // worker computed 3628800

    // a panic is reported through join, not propagated automatically
    let bad = thread::spawn(|| -> u32 { panic!("boom") });
    println!("thread panicked: {}", bad.join().is_err()); // true (panic message goes to stderr)

    // scoped threads may borrow locals
    let data: Vec<u64> = (1..=1_000).collect();
    let (left, right) = data.split_at(data.len() / 2);
    let (a, b) = thread::scope(|s| {
        let h1 = s.spawn(|| left.iter().sum::<u64>());
        let h2 = s.spawn(|| right.iter().sum::<u64>());
        (h1.join().unwrap(), h2.join().unwrap())
    });
    println!("{} = {}", a + b, data.iter().sum::<u64>()); // 500500 = 500500
}

Remember

  • thread::spawn needs a Send + 'static closure, so move owned data in
  • join() returns the result, or Err if the thread panicked
  • thread::scope lets threads borrow local data because they are joined before it returns

Watch out for

  • Spawning a thread per small task; use a pool (rayon) or async for many tiny units of work.

Check yourself

  1. Why can a closure passed to `thread::scope`'s `s.spawn` borrow a local Vec, but one passed to `thread::spawn` cannot?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: doc.rust-lang.org/book/ch16-01-threads.html