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::spawnandJoinHandle: how to start threads, get their return values, and contain their panics.thread::Builder: how to name threads and set stack sizes, and why itsspawnreturnsio::Result.thread::park/unpark: the low-level blocking primitive and its token semantics.thread::scope: how to borrow stack data across threads withoutArcor'staticbounds.thread_local!andLocalKey<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).
spawnreturnsio::Resultbecause the creation of an OS thread can fail. The process may reach its thread limit, or the OS may refuse the stack allocation. Plainthread::spawnunwraps this result internally, so it panics whereBuilder::spawnlets 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:
parkmay wake spuriously. Never assume that a return fromparkmeans that a different thread calledunpark.- Check a real condition in a loop. Call
parkbetween 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
| Concept | Key point |
|---|---|
thread::spawn | Starts a new OS thread. The closure must be 'static (it owns its data through move). |
JoinHandle::join | Blocks until the thread finishes. Returns Ok(value) or Err(panic payload). |
| Joins synchronize | Assert on state after the joins, never on the interleaving order. |
| Worker panics | A panic stays in its thread. join returns it as Err. |
thread::Builder | Sets the name and the stack size. spawn returns io::Result, so you can recover from a failure. |
park / unpark | Token-based blocking. Always check a condition again in a loop. |
thread::scope | Threads 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
| File | Description |
|---|---|
06_01_spawn_join.rs | spawn, join, move closures, panic payloads, thread identity |
06_02_builder_park.rs | Builder name and stack size, fallible spawn, park/unpark handshake |
06_03_scoped_threads.rs | thread::scope: shared and disjoint-mutable borrows, nested spawns |
06_04_thread_local.rs | thread_local!, LocalKey accessors (update since 1.99), per-thread scratch buffer |