The Rust Engineering Handbook / Chapter 53
Threads, Scoped Threads, and Work Decomposition
Partition ownership and CPU work across OS threads with explicit lifetime, panic, resource, and joining contracts.
This program is rejected for a useful reason:
let values = vec![10_u64, 20, 30];
let worker = std::thread::spawn(|| values.iter().sum::<u64>());
println!("still have {} inputs", values.len());
let total = worker.join().unwrap();
The spawned closure borrows values, but the handle can be forgotten and the new OS thread can outlive the stack frame that owns the vector. Joining later does not change the type of spawn: Rust checks the closure at the call boundary, before control flow can promise that a later join will happen. Adding move would transfer the vector and make the closure eligible for the required lifetime, but then the parent could not use values. Cloning would compile while manufacturing a second allocation and copy that this computation does not need.
The design question is therefore not “how do I satisfy 'static?” It is: which owner should retain each input, how long may each worker exist, and where will results and failures be collected? An ordinary spawned thread is appropriate for independently owned work. A scoped thread is appropriate when the parent remains the owner and can prove that every borrowing worker finishes before the scope exits.
The Send/Sync capability test establishes whether a value is allowed to cross a thread boundary. A usable thread design must add the temporal and operational contracts: the thread’s lifetime, its resource footprint, its join path, and the decomposition that makes parallel work worthwhile.
A spawn transfers a closure, not a vague block of work
std::thread::spawn accepts a FnOnce() -> T closure that is Send + 'static; its return value must also be Send + 'static. FnOnce matters because the closure may consume captured values. Send authorizes moving the closure and result between threads. The 'static bound says the closure contains no borrowed dependency whose validity is shorter than the unconstrained spawned thread. It does not say the closure runs forever, and it does not require every owned value inside it to be globally allocated.
Use move when ownership truly belongs to the worker:
let batch = vec![10_u64, 20, 30];
let worker = std::thread::spawn(move || {
batch.into_iter().sum::<u64>()
});
let total = worker.join().expect("sum worker panicked");
assert_eq!(total, 60);
The vector allocation is not necessarily copied. Ownership of the Vec handle moves into the closure; the worker consumes the elements; the result moves back through JoinHandle. This creates a clean lane ledger:
| Point | Parent owns | Worker owns | Cross-lane event |
|---|---|---|---|
before spawn |
batch |
nothing | none |
after successful spawn |
JoinHandle<u64> |
closure and batch |
closure ownership transferred |
| while running | handle | batch and partial accumulator | no shared mutation |
after join |
u64 result |
nothing | result or panic payload transferred |
A moved capture is not automatically the right architecture. If the parent needs the input after the work, decide whether it needs the same owned object, an immutable view, or a separately owned copy. Arc<[T]> may express long-lived shared input, but it adds allocation strategy and atomic lifetime bookkeeping. A scoped borrow often states the shorter relationship more accurately.
Drop follows the new owner. A file, socket, temporary directory, or foreign handle moved into the closure will normally be dropped on the worker thread when the closure releases it, including during unwinding. That is useful for ordinary owned resources and wrong for a thread-affine resource whose destructor must run on its creator. Chapter 52’s capability restriction should prevent the latter from moving; a wrapper with an incorrect unsafe Send implementation can turn this innocent-looking spawn into a safety defect.
The join result does not transfer captured resources back automatically. It transfers only the closure’s return value. If the parent must regain a reusable resource, the worker must return it explicitly, place it into an owner-governed channel, or keep the worker alive as the resource owner. Returning (resource, result) makes the normal path visible, but panic can still drop the resource on the worker. A persistent owner lane is often a better model for thread-affine or expensive-to-create state.
Closure capture can also be narrower than expected. A move closure may capture a field rather than an entire aggregate when the compiler can do so, and Copy captures may be copied into the closure rather than making the original binding unavailable. Reason from the values the closure actually uses and confirm important ownership boundaries with a small compilation witness. Do not use “the struct moved” as a review claim when only one field crossed.
Finally, the spawned closure begins after a successful creation call, but scheduling order relative to the parent is not guaranteed. The child may finish before the parent stores the handle, or may start much later. Any initialization the child needs must be captured before spawn or coordinated with a synchronization mechanism. A log line placed after spawn is not evidence that the parent action happened before the child’s first instruction.
The scope makes the join deadline part of the type
std::thread::scope creates a region in which spawned threads may borrow from the caller. The library guarantees that all scoped threads are joined before scope returns. Because the borrow cannot escape that region, it may point into the caller’s stack or into a vector the caller continues to own.
let values = [10_u64, 20, 30, 40];
let (left, right) = std::thread::scope(|scope| {
let left = scope.spawn(|| values[..2].iter().sum::<u64>());
let right = scope.spawn(|| values[2..].iter().sum::<u64>());
(left.join().unwrap(), right.join().unwrap())
});
assert_eq!(left + right, 100);
assert_eq!(values.len(), 4);
The child closures borrow disjoint shared slices, although sharing the same immutable input would also be legal when the element type supports it. The parent cannot leave the scope while either borrow remains usable by a worker. After the implicit join boundary, the borrows have ended and values is available again.

Read the figure from left to right. In the upper model, ownership moves and the parent retains only join handles. In the lower model, the parent retains ownership, workers receive time-bounded borrows, and the heavy join boundary closes every worker lane before the scope may return. Send answers whether the captured access can cross; the scope answers whether its lifetime remains valid.
Scoped threads are not detached tasks and not a way to ignore joins. Their value is structured lifetime containment. They also do not make arbitrary aliasing legal: multiple workers can share &T when T: Sync, and disjoint mutable slices can be distributed when the partition establishes exclusivity. Two workers cannot receive overlapping mutable borrows merely because both are scoped.
Partition data before scheduling work
Parallelism is easiest to reason about when each worker owns its write set and produces a private result. The fixture’s parallel_sum_scoped function borrows a slice, divides it into nonempty chunks, and reduces each chunk independently:
pub fn parallel_sum_scoped(
values: &[u64],
worker_count: NonZeroUsize,
) -> Result<u64, ParallelSumError> {
if values.is_empty() {
return Ok(0);
}
let lane_count = worker_count.get().min(values.len());
let chunk_size = values.len().div_ceil(lane_count);
thread::scope(|scope| {
let mut joins = Vec::with_capacity(lane_count);
for (lane, chunk) in values.chunks(chunk_size).enumerate() {
let join = thread::Builder::new()
.name(format!("sum-{lane}"))
.stack_size(256 * 1024)
.spawn_scoped(scope, move || {
chunk.iter().try_fold(0_u64, |sum, value| {
sum.checked_add(*value)
})
})
.expect("the operating system must create each worker");
joins.push(join);
}
joins.into_iter().try_fold(0_u64, |total, join| {
let partial = join
.join()
.map_err(|_| ParallelSumError::WorkerPanicked)?
.ok_or(ParallelSumError::TotalOverflow)?;
total.checked_add(partial)
.ok_or(ParallelSumError::TotalOverflow)
})
})
}
Three design choices do more work than the threading syntax.
First, the number of lanes is capped by the number of input elements. Creating empty workers adds scheduling and stack reservations without adding useful work. In a production implementation, the cap should also reflect available_parallelism, workload measurements, and any process-wide concurrency budget. The operating system’s available-parallelism estimate is an input to policy, not a mandate to spawn that many threads for every call.
Second, each worker returns a private partial sum. There is no shared accumulator, lock, or atomic update on the hot path. The parent serially reduces at most one result per lane. That shape avoids contention and makes overflow explicit at both the local and final reduction. For non-associative operations—floating-point summation, order-sensitive merges, or domain-specific conflict resolution—the partition and reduction order are part of correctness, not just performance.
Third, Builder::spawn_scoped names lanes and deliberately chooses a stack size. Names appear in panic messages and diagnostics, which can turn “an unnamed thread failed” into an actionable lane identifier. Stack sizing is a resource policy: too small risks stack overflow for deep call chains; too large reserves address space needlessly. The fixture’s value demonstrates the control, not a universal production recommendation. Measure real call depth and respect platform behavior.
The calculation is CPU-bound and has enough independent work to justify parallel consideration. Spawning a fresh OS thread around a short database call or a handful of integers is usually negative value. Thread creation, scheduling, cache movement, joins, and reduction all cost time. Establish a sequential baseline and a minimum work size before selecting parallel execution.
Disjoint mutation is a partition proof
Shared slices make the sum easy because every worker only reads. Parallel mutation can keep the same ownership shape when the parent divides one mutable slice into nonoverlapping mutable slices before spawning. Methods such as chunks_mut and split_at_mut establish exclusivity in safe code: each element belongs to at most one child borrow for the scope.
let mut readings = [3_i64, -1, 8, -4, 5, 0];
std::thread::scope(|scope| {
for chunk in readings.chunks_mut(2) {
scope.spawn(move || {
for reading in chunk {
*reading = (*reading).max(0);
}
});
}
});
assert_eq!(readings, [3, 0, 8, 0, 5, 0]);
The closure’s move moves the mutable reference value into one child; it does not move the array. The parent cannot access readings while those exclusive borrows are live. The scope’s join boundary ends all child borrows, after which the parent regains access to the whole array. No mutex is needed because the partition proves that write sets do not overlap.
This proof becomes harder when records have cross-partition relationships. Deduplication, graph traversal, union-find, and global ranking may need coordination or a different algorithm. Forcing those operations into arbitrary chunks can create duplicate work, boundary repair, or synchronization that costs more than parallelism saves. Useful decompositions often follow domain ownership: account shard, file segment, image tile with a defined halo, independent syntax tree, or key range.
Even disjoint logical elements can occupy the same cache line. Two workers repeatedly writing adjacent counters may contend through cache coherence despite having no Rust aliasing violation. Padding, coarser partitions, worker-private accumulation, and a final merge can help, but false-sharing claims require hardware-counter or benchmark evidence on the target. The compiler’s acceptance establishes memory safety, not cache independence.
Mutable output also needs a deterministic assembly rule. Writing disjoint fixed positions preserves order naturally. Appending variable amounts into private vectors and concatenating by partition index preserves a planned order. Sending results as workers finish changes order unless the parent tags and sorts them. Choose based on the operation’s semantic contract; do not let scheduler completion order silently become an API result.
The partition review should therefore name four facts: the input domain assigned to each lane, its exclusive write set, any boundary data it may read, and the deterministic merge. If any element can be written by two workers, the design needs a synchronization protocol or a different partition—not an assertion that the collision is unlikely.
Oversubscription moves delay rather than removing it
Libraries are often called inside servers, test runners, build systems, and other parallel libraries. If each call independently creates available_parallelism() workers, nested operations can multiply runnable threads far beyond the effective CPU allocation. The result may be context-switching, larger aggregate stack reservations, cache eviction, and less predictable tail latency.
A reusable library should avoid secretly owning a large global concurrency policy. Credible choices include accepting a caller-supplied worker budget, exposing a sequential primitive that a higher layer schedules, sharing an explicitly documented pool, or adding a measured threshold and conservative cap. A command-line application owns more of the process and can reasonably select a default from the environment, while still allowing operator control.
CPU affinity and priority are platform-specific concerns and are not portable guarantees of std::thread. If workload isolation depends on pinning or scheduler classes, put that behavior behind a platform boundary, test failure modes, and state the operational requirement. Thread names aid diagnosis but do not enforce scheduling.
Finally, distinguish throughput from latency. Parallel decomposition may increase total completions while making a small individual request slower. A service can reserve a pool for batch throughput, keep latency-sensitive work sequential, or admit only jobs above a size threshold. The decision needs the workload distribution and service objective, not only a microbenchmark of the largest input.
Joins are the failure boundary
A JoinHandle<T> is both a result path and a lifecycle obligation. join returns Ok(T) when the closure completes normally and Err(payload) when it panics. The panic payload is type-erased; production code usually maps it to a domain-level worker failure, records the thread identity and operation context, and decides whether the remaining result is still meaningful.
Dropping a normal JoinHandle detaches the thread. It does not cancel the work or wait for cleanup. A process can therefore lose knowledge of completion while the thread continues holding files, memory, locks, or external leases. If the thread belongs to an operation, service component, or request lifetime, retain the handle in an owner that has an explicit shutdown and join path.
Scoped threads tighten this behavior but still require a panic policy. If a scoped child panics and its handle was not explicitly joined, scope will panic after joining all children. If you join handles yourself, you can translate each panic as the fixture does. This translation does not make the partial computation trustworthy. Decide whether one failed lane invalidates the whole reduction, whether other lanes should finish, and whether side effects are idempotent or compensatable.
Thread creation can fail before a handle exists. thread::spawn panics on creation failure, while Builder::spawn and Builder::spawn_scoped expose an io::Result. The fixture uses expect because it is a bounded teaching artifact and states the environment invariant. A reusable library or service should propagate or classify creation failure, and must handle the fact that earlier lanes may already be running when a later spawn fails. A robust launcher may create workers during component initialization, roll back partial startup, or fall back to sequential execution only when that behavior is explicitly acceptable.
Panic is not cancellation. Rust’s standard threads do not provide forced safe termination. Cooperative stopping requires a channel, atomic flag, condition variable, or another protocol checked at defined points. The owner still joins afterward. Killing an OS thread in the middle of mutation could strand invariants and foreign resources, which is why the safe abstraction does not promise it.
Worker ownership should be long-lived when work is frequent
Repeatedly creating threads couples latency and resource use to each unit of work. A bounded worker model separates worker lifetime from job lifetime:
- Initialize a fixed number of named workers.
- Give each worker ownership of its receiver and any thread-local resource.
- Transfer owned jobs or scoped partitions through an explicit dispatch boundary.
- Bound pending work and define what producers do at saturation.
- Close intake, drain or reject according to policy, and join every worker during shutdown.
This model does not require shared mutable business state. A worker can own a parser scratch buffer, compression context, database connection, or shard of a data structure and process commands serially. Ownership follows the lane; only messages and results cross it. That can be simpler than a shared Arc<Mutex<State>>, although it replaces lock-wait behavior with queueing and lifecycle behavior. Chapter 54 develops that trade.
There are several credible shapes:
| Shape | Favors | Costs and rejection signal |
|---|---|---|
| fresh owned thread | rare, independent, coarse work | creation and join cost; reject for frequent tiny jobs |
| scoped partition | borrowed input and structured completion | parent blocks until all finish; reject if work must outlive the call |
| fixed worker owners | repeated blocking or CPU work with bounded concurrency | queue and shutdown protocol; reject if long jobs create unacceptable head-of-line blocking |
| data-parallel library | mature scheduling and work stealing for pure partitions | dependency and global-pool policy; reject when thread placement or per-worker resource ownership must be explicit |
| sequential loop | small, ordered, or memory-bound work | no parallel speedup; prefer when overhead dominates |
An OS thread is the right boundary for blocking calls that would otherwise stall a dedicated scheduler, thread-affine libraries, and sustained CPU work. It is not automatically the right boundary for every concurrent activity. Async tasks, processes, and event loops have different scheduling, isolation, cancellation, and memory contracts.
Thread-local state is ownership, not aggregation
Thread-local storage gives each thread a separate instance of a value. It is useful for caches, foreign-runtime context, diagnostic context, or scratch state whose identity truly follows the OS thread. It is not a shared counter with faster syntax. Each worker’s instance must be initialized, observed, and cleaned up according to the thread’s lifecycle.
Thread-local destructors have platform and shutdown caveats, and code must not assume that another thread can collect their state after exit. If the parent needs totals, return them through joins or messages. If work may move among workers, task identity must not silently depend on thread-local state. Async runtimes make that distinction especially important because a task can be polled on different worker threads unless the runtime contract says otherwise.
Treat thread-local storage as a private worker field with unusual access syntax. Document why the value belongs to the lane, whether initialization can fail, what happens during panic, and how observable results leave the thread.
Blocking must fit the scheduler and capacity budget
An OS thread may block in the kernel without violating Rust’s memory model. That says nothing about throughput. If every worker waits on slow I/O, a fixed CPU-sized pool can stop useful progress. If a pool grows without a bound to compensate, stacks and scheduler overhead become the new failure mode.
Classify the work:
- CPU-bound work needs enough computation per partition and usually no more active lanes than the effective CPU budget.
- Blocking I/O needs a concurrency bound derived from latency, downstream capacity, memory per operation, and service objectives.
- Thread-affine work needs one or more persistent owner lanes, often with command queues.
- Mixed work should separate phases or use distinct bounded pools so a slow blocking phase cannot consume every CPU lane.
Instrument active workers, queued work, job duration, join latency, panics, spawn failures, and shutdown time. Thread names should appear consistently in logs and traces. A high worker-utilization percentage is not inherently good: it may mean useful saturation, downstream blocking, a deadlock, or work that should have been rejected earlier.
Exercise: parallelize without shared mutable state
Level: Integrate. Extend the fixture from summing integers to validating and hashing a large collection of immutable byte records. The sequential function already returns records in input order and reports the first invalid record by index.
Your deliverable is a design note, implementation, and tests that:
- choose a partition size from a supplied worker budget rather than spawning per record;
- borrow input records through scoped threads without cloning record bytes;
- keep each worker’s output private and reconstruct deterministic input order in the parent;
- preserve the lowest invalid index even when a later partition finishes first;
- map worker panic separately from validation failure;
- handle partial thread-creation failure or explain why initialization makes it unreachable in the chosen boundary;
- compare the measured release-profile result with the sequential baseline on at least two input sizes;
- identify the threshold below which the sequential path remains the default.
Do not add a shared mutex-protected output vector. Multiple solutions are valid, but the ownership map, failure policy, deterministic reduction, and evidence must agree. The review fails if “uses all cores” is offered as a capacity argument without measuring the effective environment.
Thread decomposition review
- Does each captured value move, borrow, or share for an explicit reason?
- If a borrow crosses the boundary, what scope proves it cannot outlive its owner?
- Is the write set disjoint, privately owned, or deliberately synchronized?
- Where are all join handles retained, and what does dropping their owner do?
- How are panic payloads, creation failure, partial results, and cooperative stopping classified?
- Are worker count, stack size, and minimum job size bounded by evidence?
- Does blocking work consume a pool needed for unrelated progress?
- Does thread-local state truly belong to the OS-thread lane?
- Can shutdown close intake, settle accepted work, and join every worker within a measured deadline?
The durable rule is to decompose ownership before decomposing execution. A thread lane should receive a value, a time-bounded borrow, or a private shard whose lifecycle the parent can close. Once those lanes need a stream of work rather than one partition, the next question is no longer how to spawn them. It is what the queue promises when producers are faster than consumers.
Sources and version note
The standard-library documentation for std::thread::spawn, std::thread::scope, ScopedJoinHandle, JoinHandle, Builder, available_parallelism, and thread_local! defines the API contracts used here. The fixture uses Rust 2024 Edition, is checked with Rust 1.97.0, and declares Rust 1.85.0 as its MSRV. Thread creation, stack reservation, scheduling, and available parallelism remain platform-sensitive; no performance conclusion follows from the example without a named target, workload, and release-profile measurement.
Continue reading
Full table of contents