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 hereThe 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()intopar_iter()with a work-stealing pool. thread::Buildersets names and stack sizes;thread::sleeppauses 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::spawnneeds aSend + 'staticclosure, somoveowned data injoin()returns the result, orErrif the thread panickedthread::scopelets 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.