Introduction
thread::spawn requires that everything the closure captures lives for 'static, which forces you to clone or move data even when a short-lived borrow would be safe. Scoped threads, stabilized in Rust 1.63, solve this by guaranteeing that every thread spawned inside a scope is joined before the scope exits — so local stack variables can be safely borrowed.
Key Concepts
- Scope: Created by
thread::scope(|s| { ... }). The closure receives a&Scopehandle used to spawn threads. The scope blocks until all spawned threads finish. - Non-
'staticBorrows: Because the scope guarantees join-before-drop, closures passed tos.spawnmay borrow local variables without'static. - Mutable Splitting: You can give disjoint mutable slices to different threads using
split_at_mut, enabling safe parallel mutation.
Real World Context
Scoped threads shine in data-parallel algorithms where you split a large buffer into chunks and process them in parallel. Think parallel sorting, image processing by scanline, or matrix operations — any time you want to borrow and mutate owned data across threads without the overhead of Arc.
Deep Dive
The core issue with thread::spawn is the 'static bound. Spawned threads might outlive the function that created them, so the compiler cannot allow borrows of local variables.
rustuse std::thread; let data = vec![1, 2, 3]; let slice = &data[..]; // This fails to compile: // thread::spawn(|| { // println!("{slice:?}"); // Error: `slice` is not 'static // });
thread::scope fixes this by creating a bounded lifetime. All threads spawned inside the scope are automatically joined when the scope closure returns, so the compiler can prove that data outlives every thread.
rustuse std::thread; let data = vec![1, 2, 3]; thread::scope(|s| { s.spawn(|| { println!("thread 1: {data:?}"); // shared borrow — OK! }); s.spawn(|| { println!("thread 2: {data:?}"); // another shared borrow — OK! }); }); // both threads joined here, before `data` is dropped println!("after scope: {data:?}");
Scoped threads also enable safe parallel mutation through disjoint mutable borrows. Use split_at_mut or chunks_mut to give each thread a unique, non-overlapping slice.
rustuse std::thread; let mut data = vec![1, 2, 3, 4, 5, 6]; let (left, right) = data.split_at_mut(3); thread::scope(|s| { s.spawn(|| { for x in left.iter_mut() { *x *= 2; } }); s.spawn(|| { for x in right.iter_mut() { *x *= 3; } }); }); println!("{data:?}"); // [2, 4, 6, 12, 15, 18]
For larger workloads, chunks_mut lets you split data into arbitrarily many pieces and process them in parallel.
rustuse std::thread; fn parallel_process(data: &mut [i32]) { let num_threads = thread::available_parallelism() .map(|n| n.get()) .unwrap_or(4); let chunk_size = (data.len() + num_threads - 1) / num_threads; thread::scope(|s| { for chunk in data.chunks_mut(chunk_size) { s.spawn(|| { for x in chunk { *x = expensive_computation(*x); } }); } }); }
Scoped thread handles also return values, just like regular JoinHandles.
rustuse std::thread; let numbers: Vec<i32> = (1..=1_000_000).collect(); let mid = numbers.len() / 2; let (left, right) = numbers.split_at(mid); let total = thread::scope(|s| { let left_handle = s.spawn(|| left.iter().sum::<i32>()); let right_handle = s.spawn(|| right.iter().sum::<i32>()); left_handle.join().unwrap() + right_handle.join().unwrap() }); println!("total: {total}");
Common Pitfalls
- Trying to use
&mutwithout splitting — Two threads cannot hold&mutreferences to the same data. Always split withsplit_at_mutorchunks_mutto create disjoint borrows. - Panics inside scoped threads — If a scoped thread panics, the scope still joins all other threads before propagating the panic. However, you may get confusing double-panic behavior if multiple threads panic.
Best Practices
- Prefer scoped threads over
Arcfor short-lived parallelism — When the parallel work is bounded and you already own the data, scoped threads avoid the overhead and complexity of reference counting. - Size chunks to available parallelism — Use
thread::available_parallelism()to avoid spawning more threads than CPU cores, which adds context-switch overhead without improving throughput.
Summary
thread::scopeguarantees all spawned threads join before the scope returns, enabling non-'staticborrows.- Disjoint mutable slices via
split_at_mutorchunks_mutallow safe parallel mutation. - Scoped threads eliminate the need for
Arcin fork-join parallelism patterns. - Scoped thread handles support return values via
join(), just like regular threads.
Code Examples
use std::thread;
fn parallel_sum(numbers: &[i32]) -> i32 {
let mid = numbers.len() / 2;
let (left, right) = numbers.split_at(mid);
let mut left_sum = 0;
let mut right_sum = 0;
thread::scope(|s| {
s.spawn(|| {
left_sum = left.iter().sum();
});
s.spawn(|| {
right_sum = right.iter().sum();
});
});
left_sum + right_sum
}
let numbers: Vec<i32> = (1..=1000).collect();
println!("Sum: {}", parallel_sum(&numbers));