Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

6.1 · Threads: Spawning, Joining, and Thread-Local Storage

Domain 6 — Concurrency and Parallelism Duration: ~15 minutes Library components: std::thread, std::thread::spawn, std::thread::Builder, std::thread::JoinHandle, std::thread::scope, thread_local!, std::thread::LocalKey

Introduction

Rust threads are real OS threads. The standard library has no green-thread runtime and no scheduler. Rust adds its type system to the OS primitive. The Send and Sync traits (Tutorial 6.8) let the compiler reject data races at the thread::spawn call site, before your program runs.

This tutorial shows:

  • thread::spawn and JoinHandle: how to start threads, get their return values, and contain their panics.
  • thread::Builder: how to name threads and set stack sizes, and why its spawn returns io::Result.
  • thread::park/unpark: the low-level blocking primitive and its token semantics.
  • thread::scope: how to borrow stack data across threads without Arc or 'static bounds.
  • thread_local! and LocalKey<T>: per-thread state that needs no synchronization.

spawn and JoinHandle

thread::spawn(f) runs the closure f on a new OS thread and immediately returns a JoinHandle<T>. T is the return type of the closure. join() blocks until the thread finishes. Then it returns Result<T, Box<dyn Any + Send>>: Ok with the return value, or Err with the panic payload.

Figure: Fork/Join Lifecycle

// The closure runs on a new OS thread and returns 42.
let handle: thread::JoinHandle<u64> = thread::spawn(|| 6 * 7);
// `join` blocks until the thread finishes. `expect` unwraps the Ok value.
let answer = handle.join().expect("worker should not panic");
assert_eq!(answer, 42);

A spawned thread may continue to run after the function that created it returns. Thus its closure must be 'static: the closure must own all the data that it uses. A move closure does this. Move owned chunks into the threads. Get the results through join:

// dataset: Vec<u64> — the values 1 to 80.
// partial_checksum(&[u64]) -> u64 returns the sum of x * x for a slice.
// Split the dataset into 4 owned chunks of 20 values, one for each worker.
let chunks: Vec<Vec<u64>> = dataset.chunks(20).map(<[u64]>::to_vec).collect();

let mut handles = Vec::new();
for (worker_id, chunk) in chunks.into_iter().enumerate() {
    // `move` transfers `chunk` and `worker_id` into the closure.
    handles.push(thread::spawn(move || (worker_id, partial_checksum(&chunk))));
}

// Join all the workers, then add the partial results.
let mut total = 0;
for handle in handles {
    let (_, sum) = handle.join().expect("worker should not panic");
    total += sum;
}
// total is 173880, the same value as partial_checksum(&dataset)

The join is the synchronization point. After join returns, all the work of the worker happens-before all your subsequent work. Thus the sum of the partial results after all the joins is deterministic. A read of shared state while the workers run is not deterministic. Each example in this domain obeys this rule: assert on values that are final after the joins, never on the interleaving order.

Panics Stay in Their Thread

A panic in a spawned thread does not stop the process. It unwinds only that thread. join reports the panic as Err with the panic payload, a Box<dyn Any + Send>. The payload is a &'static str for a panic with a literal message, and a String for a formatted message:

// The closure panics. The panic unwinds only the spawned thread.
// The default panic hook also prints a message to stderr.
// `06_01_spawn_join.rs` replaces the hook to keep that message out of its output.
let doomed = thread::spawn(|| panic!("sensor offline"));
let outcome = doomed.join();   // Err(Box<dyn Any + Send>)
assert!(outcome.is_err());

let payload = outcome.unwrap_err();
// A literal panic message is a &'static str. `downcast_ref` returns None for other types.
let message = payload
    .downcast_ref::<&str>()
    .copied()
    .unwrap_or("<non-string panic payload>");
assert_eq!(message, "sensor offline");

Thus join().expect(...) is the standard pattern. It panics in the parent thread when a worker panicked, so a failure propagates and does not disappear.

Each thread has a Thread handle, which thread::current() returns. The handle has an id() and an optional name(). The name of the main thread is always "main". Threads from plain spawn have no name.

thread::Builder: Names, Stack Sizes, and Fallible Spawning

thread::Builder configures a thread before the thread starts:

// This code is in a function that returns io::Result, so `?` can propagate the error.
// `06_02_builder_park.rs` calls `expect` in place of `?`.
let handle = thread::Builder::new()
    .name("metrics-flusher".to_string())   // thread::current().name() returns this name
    .stack_size(512 * 1024)                // 512 KiB (the default is 2 MiB)
    .spawn(|| { /* ... */ })?;             // spawn returns io::Result<JoinHandle<T>>
  • Names appear in panic messages, debuggers, and profilers. Give a name to each long-lived thread.
  • Stack size is important for workers with deep recursion (larger) and for large numbers of small threads (smaller).
  • spawn returns io::Result because the creation of an OS thread can fail. The process may reach its thread limit, or the OS may refuse the stack allocation. Plain thread::spawn unwraps this result internally, so it panics where Builder::spawn lets you recover. Services that spawn threads on demand should use the builder.

park and unpark: The Primitive Underneath

thread::park() blocks the current thread. Thread::unpark() wakes a specific thread. Each thread owns one wake-up token. unpark makes the token available. park consumes the token, and it returns immediately if the token was already available. Thus an early unpark still has its effect, and the order of park and unpark is not critical.

Two rules make park-based code correct:

  1. park may wake spuriously. Never assume that a return from park means that a different thread called unpark.
  2. Check a real condition in a loop. Call park between the checks:
// go: Arc<AtomicBool>, initially false. The worker and the coordinator share it.
// worker: the JoinHandle of the worker thread.

// Worker: park until `go` is true. The loop also handles spurious wakeups.
while !go.load(Ordering::Acquire) {
    thread::park();
}

// Coordinator: publish the condition FIRST, then wake.
go.store(true, Ordering::Release);
worker.thread().unpark();   // `JoinHandle::thread` returns the Thread handle

Condvar requires the same order: set the condition, then wake (Tutorial 6.2). Channels use park/unpark internally when they must block, and on some platforms Once does too. Use park/unpark directly only when you build such primitives yourself.

Scoped Threads: Borrowing Without Arc

thread::spawn requires 'static, so its closure must own its data. thread::scope removes this requirement by its structure. It guarantees that it joins each thread spawned in the scope before it returns. Thus scoped threads can borrow local variables, even mutably.

Figure: Scope Guarantees Joins Before Borrows End

let readings: Vec<f64> = vec![22.1, 23.4, 24.0, 22.8, 21.9, 23.1, 24.6, 22.2];

let (min, max, sum) = thread::scope(|s| {
    // Each closure borrows `readings` by shared reference.
    let min_handle = s.spawn(|| readings.iter().copied().fold(f64::INFINITY, f64::min));
    let max_handle = s.spawn(|| readings.iter().copied().fold(f64::NEG_INFINITY, f64::max));
    let sum_handle = s.spawn(|| readings.iter().sum::<f64>());

    // `ScopedJoinHandle::join` works the same as `JoinHandle::join`.
    (
        min_handle.join().expect("min worker should not panic"),
        max_handle.join().expect("max worker should not panic"),
        sum_handle.join().expect("sum worker should not panic"),
    )
    // `thread::scope` returns the value of its closure.
});
// min is 21.9, max is 24.6, sum is approximately 184.1

The code has no move, no Arc, and no clone. The borrow checker can see that no thread continues after the scope. Mutable access is also possible if the borrows are disjoint:

let mut samples: Vec<u32> = (1..=8).collect();   // [1, 2, 3, 4, 5, 6, 7, 8]
// `split_at_mut` returns two mutable slices that do not overlap.
let (front, back) = samples.split_at_mut(4);

thread::scope(|s| {
    s.spawn(|| {
        for value in front.iter_mut() {
            *value *= 10;
        }
    });
    s.spawn(|| {
        for value in back.iter_mut() {
            *value *= 100;
        }
    });
});   // no explicit joins: the scope joins the two threads here
assert_eq!(samples, [10, 20, 30, 40, 500, 600, 700, 800]);

At its end, the scope automatically joins the threads that you did not join, and it propagates their panics. thread::scope returns the value that its closure returns. Spawned threads can also use the Scope handle to spawn more threads in the same scope.

Thread-Local Storage: thread_local! and LocalKey

thread_local! declares a static for which each thread gets its own independent copy. It needs no locks and no atomics, and it has no contention:

thread_local! {
    // A `const { … }` initializer is the fast path: no lazy-init check on each access.
    static OPS_COUNTER: Cell<u64> = const { Cell::new(0) };
    // A non-const initializer runs lazily, one time for each thread, on the first access.
    static SCRATCH: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(64));
}

The type of the declared name is LocalKey<T>. To get access, call with. It gives the copy of the current thread to a closure. Keys with a Cell also have the direct helpers get, set, take, and replace. Since Rust 1.99, they also have update:

// OPS_COUNTER and SCRATCH are the thread-locals from the previous snippet.

// Each of these two lines adds 1 to the counter of the current thread.
OPS_COUNTER.set(OPS_COUNTER.get() + 1);   // direct helpers of a Cell key, no `with` closure
OPS_COUNTER.update(|ops| ops + 1);        // 1.99: the same read-modify-write in one call

/// Encodes a message in the scratch buffer of the current thread.
fn encode(message: &str) -> usize {
    SCRATCH.with(|scratch| {
        // scratch: &RefCell<Vec<u8>> — the copy of the current thread
        let mut buf = scratch.borrow_mut();
        buf.clear();   // removes the old bytes and keeps the allocation
        buf.extend_from_slice(message.as_bytes());
        buf.push(b'\n');
        buf.len()      // encode("telemetry: cpu=42%") returns 19
    })
}

with gives only a shared reference, so you need interior mutability (Cell, RefCell). No other thread can get access to this copy, so the single-threaded cell types are sufficient. The typical use is a scratch buffer for each thread: each thread allocates the buffer one time and uses it again with no synchronization. The destructor of a thread-local runs when the thread exits. The copy of the main thread is fully independent of the copies of the workers.

06_04_thread_local.rs prints:

worker 1 counted 10 ops in its own thread-local
worker 2 counted 20 ops in its own thread-local
worker 3 counted 30 ops in its own thread-local
worker 4 counted 40 ops in its own thread-local
main thread's copy: 0
encoded 19 bytes via thread-local scratch

All assertions passed.

Summary

ConceptKey point
thread::spawnStarts a new OS thread. The closure must be 'static (it owns its data through move).
JoinHandle::joinBlocks until the thread finishes. Returns Ok(value) or Err(panic payload).
Joins synchronizeAssert on state after the joins, never on the interleaving order.
Worker panicsA panic stays in its thread. join returns it as Err.
thread::BuilderSets the name and the stack size. spawn returns io::Result, so you can recover from a failure.
park / unparkToken-based blocking. Always check a condition again in a loop.
thread::scopeThreads borrow stack data (even &mut). The scope joins them at its end.
thread_local!Each thread has an independent copy. A const { … } initializer is the fast path.
LocalKey<Cell<T>>Direct get/set/take without a with closure. update since 1.99.

Code Examples

FileDescription
06_01_spawn_join.rsspawn, join, move closures, panic payloads, thread identity
06_02_builder_park.rsBuilder name and stack size, fallible spawn, park/unpark handshake
06_03_scoped_threads.rsthread::scope: shared and disjoint-mutable borrows, nested spawns
06_04_thread_local.rsthread_local!, LocalKey accessors (update since 1.99), per-thread scratch buffer