Fork-Join Parallelism
Master the fork-join concurrency pattern using Rust threads and scoped execution to split computation across cores, safely share immutable data, and handle errors from parallel subtasks.
Fork-join parallelism is a strategy where a single task is divided into independent subtasks that run concurrently, then the main task waits for all subtasks to finish and combines their results. The "fork" step splits the work; the "join" step collects and merges the outcomes. It is the backbone of divide-and-conquer algorithms, data-parallel pipelines, and any workload that benefits from multiple cores working at the same time without ongoing coordination.
In Rust, you implement fork-join manually with std::thread::spawn and JoinHandle::join, but modern Rust offers scoped threads (std::thread::scope) that make it dramatically easier to borrow local data instead of transferring ownership. This section covers both approaches, shows how to handle errors that occur in parallel, and explains how immutable data can be shared across threads with zero synchronization overhead.
How Spawn-and-Join Works at the Thread Level
The lowest-level building block is creating a new OS thread that runs a closure, then waiting for it to finish. Every thread::spawn call returns a JoinHandle<T>, where T is the return type of the closure. When you call handle.join(), the calling thread blocks until the spawned thread completes, and you get back a Result<T> — Ok(value) on success, or Err(boxed panic) if the thread panicked.
This pattern works for fork-join: you spawn a thread for each subtask, keep its handle, and later join all handles. The following example sums the values of a binary tree using two parallel threads.
use std::thread;
struct Tree {
val: u64,
left: Option<Box<Tree>>,
right: Option<Box<Tree>>,
}
fn sum_tree(tree: &Tree) -> u64 {
let left_sum;
let right_sum;
// Capture references to subtrees, but we must ensure the tree lives long enough.
// Using scoped threads is better; here we clone or use Arc, but for illustration:
// We'll use scoped threads later — this naive version requires 'static data.
// (The unsafe version is omitted intentionally; scoped threads solve this.)
unimplemented!("use scoped threads instead")
}
Lifetime Problem:
A bare thread::spawn requires that all captured data be 'static. Local references, like &tree.left, won't compile because the compiler cannot guarantee the parent function outlives the thread. You could wrap data in Arc, but that adds allocation and reference counting overhead. Scoped threads are the idiomatic fix.
The fundamental challenge with raw spawn-and-join is lifetimes. The closure might run after the parent scope has been destroyed, so the borrow checker rejects references to stack variables. Scoped threads eliminate this problem by tying the spawned threads' lifetimes to the scope block — the borrow checker knows all spawned threads are joined before the scope ends.
Scoped Threads for Safe Stack Borrowing
Rust 1.63 stabilized std::thread::scope, which creates a scope where you can spawn threads that borrow local variables with non-'static lifetimes. The scope function takes a closure that receives a Scope object. Inside, you call scope.spawn(...) instead of thread::spawn. The scope will automatically join all spawned threads before it returns, so you don't even need to collect handles manually.
use std::thread;
struct Tree {
val: u64,
left: Option<Box<Tree>>,
right: Option<Box<Tree>>,
}
fn sum_tree(tree: &Tree) -> u64 {
let (mut left_sum, mut right_sum) = (0, 0);
thread::scope(|s| {
// Spawn two threads that borrow the subtrees from the parent.
s.spawn(|| {
left_sum = sum_subtree(&tree.left);
});
s.spawn(|| {
right_sum = sum_subtree(&tree.right);
});
// Both spawned threads are guaranteed to be joined when this closure ends.
});
left_sum + right_sum + tree.val
}
fn sum_subtree(node: &Option<Box<Tree>>) -> u64 {
match node {
Some(b) => sum_tree(b),
None => 0,
}
}
The key detail is that left_sum and right_sum are mutable local variables. The spawned closures capture &mut left_sum and &mut right_sum — they are disjoint borrows. The borrow checker allows this because the two threads never access the same variable, eliminating data races at compile time.
The closure passed to thread::scope can even return a value, but here we mutate the local variables from the outer scope. If you prefer returning values, you can collect the join handles manually:
use std::thread;
fn sum_tree(tree: &Tree) -> u64 {
let left_val;
let right_val;
thread::scope(|s| {
let left_handle = s.spawn(|| sum_subtree(&tree.left));
let right_handle = s.spawn(|| sum_subtree(&tree.right));
left_val = left_handle.join().unwrap_or(0);
right_val = right_handle.join().unwrap_or(0);
});
left_val + right_val + tree.val
}
No Allocation Overhead:
Scoped threads involve no Arc, no heap allocation for the closure environment beyond what the OS thread needs. The borrow checker proves safety purely through lifetime analysis. This makes fork-join with scoped threads extremely lightweight.
The mental model for scoped threads is: the scope function creates a "bubble" of time where all spawned threads are guaranteed to end before the bubble pops. Any data that lives at least as long as the bubble can be borrowed by those threads, and the compiler enforces that the threads are all joined before the original data goes out of scope.
Error Handling Across Threads
In fork-join parallelism, individual subtasks may fail. A thread can panic, or it might return a Result that indicates a logical error. Since join() already returns a Result that captures panics, you need a strategy to propagate errors from multiple threads cleanly.
Propagating the First Error
If any subtask fails, the entire computation should stop and report that error. You can use a shared AtomicBool or a channel to signal cancellation, but the simplest approach is to join threads in order and short-circuit on the first error.
use std::thread;
use std::sync::mpsc;
fn parallel_compute(data: &[i32]) -> Result<i32, String> {
let (tx, rx) = mpsc::channel();
let mid = data.len() / 2;
let left = &data[..mid];
let right = &data[mid..];
thread::scope(|s| {
let tx1 = tx.clone();
s.spawn(move || {
let sum = left.iter().sum();
tx1.send(sum).unwrap();
});
s.spawn(move || {
let sum = right.iter().sum();
tx.send(sum).unwrap();
});
});
// After scope, both threads have finished.
let sum_left = rx.recv().map_err(|_| "left thread panicked or dropped")?;
let sum_right = rx.recv().map_err(|_| "right thread panicked or dropped")?;
Ok(sum_left + sum_right)
}
In this example, a channel carries the result. If a thread panics, the Send end is dropped, and recv returns an error. But if both threads send successfully, we collect both results.
Panic Catching is Not Always Needed:
If a thread panics, the default behaviour is to propagate the panic to the parent thread when JoinHandle::join is called. You can catch it with std::panic::catch_unwind if you need to wrap the computation in a recoverable manner, but for most fork-join workloads, letting the panic propagate is acceptable.
Collecting Multiple Results with Result Types
When subtasks return Result, you want to gather all errors, not just the first. A common pattern is to join handles and accumulate results in a vector, then decide if any failed.
use std::thread;
fn parallel_work(items: Vec<i32>) -> Vec<Result<i32, String>> {
let n = items.len();
let chunk_size = (n / 2).max(1);
let mut handles = vec![];
thread::scope(|s| {
for chunk in items.chunks(chunk_size) {
let handle = s.spawn(move || {
// Each chunk returns a Result
chunk.iter()
.map(|&x| if x < 0 { Err(format!("negative: {}", x)) } else { Ok(x * 2) })
.collect::<Vec<_>>()
});
handles.push(handle);
}
});
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
}
Here flat_map merges the nested Vec<Result<...>> into a flat list. You could later inspect which items failed.
No Shared Mutable State Needed:
When each subtask works on an independent slice of the input, error handling becomes straightforward: just return a Result and collect. There is no contention over a shared error accumulator, which avoids lock overhead.
Sharing Immutable Data Across Threads
Immutable data is inherently thread-safe. If multiple threads only read the same data, no synchronization is required — no Mutex, no RwLock, no atomics. Rust's &T guarantees immutability through the borrow rules, and scoped threads let you pass shared references freely.
use std::thread;
fn parallel_filter(elements: &[i32], threshold: i32) -> Vec<i32> {
let mid = elements.len() / 2;
let (left_slice, right_slice) = elements.split_at(mid);
let mut left_result = vec![];
let mut right_result = vec![];
thread::scope(|s| {
// Both closures share `threshold` by immutable reference.
s.spawn(|| {
left_result = left_slice.iter().filter(|&&x| x > threshold).copied().collect();
});
s.spawn(|| {
right_result = right_slice.iter().filter(|&&x| x > threshold).copied().collect();
});
});
left_result.extend(right_result);
left_result
}
The threshold value threshold: i32 is Copy, so it is automatically copied into each closure. However, if you pass a reference to a large data structure, both closures hold &SomeData, and no copying of the actual data occurs. The borrow checker ensures the data lives long enough because the scope ends before the original data goes out of scope.
For non-Copy types like a large configuration struct, simply pass a reference into the closures:
let config = Config { /* many fields */ };
thread::scope(|s| {
s.spawn(|| process_part_a(&config));
s.spawn(|| process_part_b(&config));
});
Rust guarantees that config is not mutated while the threads are running because the closures only capture &Config. The borrow checker rejects any attempt to take &mut config inside the scope block while threads are alive.
Beware of Interior Mutability:
If the immutable reference points to a type with interior mutability (e.g., Mutex, RefCell, AtomicUsize), multiple threads could still cause data races unless the inner type is thread-safe. A &Mutex<T> allows locking, which is safe; a &RefCell<T> is not Sync and will cause a compile error if you try to share it across threads.
When you need to share truly immutable data across many threads, the natural choice is a reference, not an Arc. Arc is only necessary when the data must live beyond the current scope or when its lifetime is dynamic. Scoped threads eliminate that requirement entirely.
Practical Patterns and Limits
Fork-join with scoped threads works well for coarse-grained parallelism — splitting a problem into a handful of large chunks. If you split into too many tiny tasks (e.g., one per array element), the overhead of spawning OS threads and performing context switches will dominate, and performance will degrade.
A reasonable rule of thumb is to create roughly one thread per available logical core. For recursive divide-and-conquer, stop spawning new threads once the subproblem size falls below a threshold, and switch to a sequential implementation.
fn parallel_sum(values: &[i64]) -> i64 {
const MIN_SIZE: usize = 1024;
if values.len() <= MIN_SIZE {
return values.iter().sum();
}
let mid = values.len() / 2;
let (left, right) = values.split_at(mid);
let (mut left_sum, mut right_sum) = (0, 0);
thread::scope(|s| {
s.spawn(|| left_sum = parallel_sum(left));
s.spawn(|| right_sum = parallel_sum(right));
});
left_sum + right_sum
}
This example recursively splits until the chunk is small enough to sum sequentially. The OS threads are reused across the recursive calls only because each thread::scope creates new threads. Creating many short-lived threads can still be wasteful — this is where a thread pool shines. Rayon's work-stealing pool handles such fine-grained parallelism efficiently without repeated thread creation.
Relationship with Rayon:
Manual fork-join is the underlying concept that Rayon's join function generalises. Rayon uses a global thread pool and splits work recursively with a join primitive that does not start a new OS thread per call. If you find yourself writing deep recursive parallel splits, using Rayon's join will almost always give better performance and simpler code. The manual approach is still valuable when you need explicit control over thread count, want to avoid external dependencies, or are working in no_std contexts.
Common Mistakes and Pitfalls
Forgetting to join a thread leads to a detached thread that may be terminated abruptly when the main thread exits. The program might even abort if the thread is in the middle of a destructor or holding a resource. Scoped threads eliminate this class of bug by design — you cannot leave the scope without all spawned threads having been joined.
Another mistake is attempting to share mutable data between scoped threads without synchronization, assuming each thread writes to a disjoint field. The borrow checker will catch this, but the error messages can be confusing. If you need to mutate disjoint fields of a struct, you can split the struct into separate mutable references:
struct Counters { a: u64, b: u64 }
let mut counters = Counters { a: 0, b: 0 };
// This won't compile:
// thread::scope(|s| {
// s.spawn(|| counters.a += 1);
// s.spawn(|| counters.b += 1);
// });
// Fix: split into separate mutable references.
let Counters { a, b } = &mut counters;
thread::scope(|s| {
s.spawn(|| *a += 1);
s.spawn(|| *b += 1);
});
Moving non-Send types into a spawned closure is another frequent compiler error. For example, Rc<T> is not Send; you must use Arc<T> if you need reference counting across threads. Scoped threads still require the closure to be Send, because the spawned thread might outlive the scope in an implementation sense — the Scope ensures it is joined, but the closure still has to be Send to be passed to another thread.
Scope Closure Must Be Send:
Even though scoped threads never outlive the scope, the spawn function requires the closure to be Send. This is because the spawned thread could technically outlive the calling thread on the OS level, and the closure is moved to the new thread. If you capture an Rc, you'll get a compile error: "Rc<i32> cannot be sent between threads safely". Replace with Arc.
Finally, a panic in one scoped thread will cause the scope to join all threads, but the panic is propagated after all threads have been joined. The other threads continue to run until they finish; they are not cancelled. If you need immediate cancellation, a channel or atomic flag must be checked manually.
Summary
Fork-join parallelism is the simplest structured concurrency pattern: divide work, spawn subtasks, then combine results after they all finish. In Rust, scoped threads (std::thread::scope) let you implement it without heap allocations or reference counting, while the borrow checker ensures that shared immutable data is truly safe and that mutable borrows are disjoint.
Manual spawn-and-join is still useful for coarse-grained parallelism where you want explicit control. Error handling can be as simple as joining handles and propagating the first panic, or you can design a result aggregation pipeline using channels or Vec<Result>. Immutable sharing comes for free through references, making a large class of parallel read-only workloads trivially safe.
For finer granularity or deeply recursive parallelism, the Rayon crate provides a high-performance work-stealing implementation that still adheres to the same fork-join model, but with dramatically lower overhead per spawned task.