Learn Rust Series (#62) - Scoped Threads: Borrowing Local Data Across Threads
Learn Rust Series (#62) - Scoped Threads: Borrowing Local Data Across Threads
What will I learn
- You will learn why
thread::spawnrefuses to borrow local variables, and what'statichas to do with it; - how
std::thread::scopelets threads borrow stack data safely by guaranteeing they finish first; - how scoped threads return values you collect after the scope closes;
- how
split_at_mutplus scoped threads mutate disjoint halves of a slice in parallel; - when scoped threads are the right tool and when you still need
Arc.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Rust toolchain (via rustup, from rustup.rs);
- The previous sixty-one episodes, especially borrowing,
Send/Sync, and threads; - The ambition to learn systems programming from the ground up.
Difficulty
- Intermediate
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn Rust Series (#17) - Associated Types vs Generic Parameters
- Learn Rust Series (#18) - Operator Overloading with std::ops
- Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion
- Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)
- Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions
- Learn Rust Series (#22) - Deriving Common Traits
- Learn Rust Series (#23) - The Orphan Rule & Trait Coherence
- Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern
- Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy
- Learn Rust Series (#26) - Const Generics: Types That Depend on Values
- Learn Rust Series (#27) - Generic Associated Types & Lending Iterators
- Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs
- Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System
- Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library
- Learn Rust Series (#31) - Move Semantics Deep Dive
- Learn Rust Series (#32) - Interior Mutability: Cell & RefCell
- Learn Rust Series (#33) - Rc Internals: Reference Counting & Shared Ownership
- Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost
- Learn Rust Series (#35) - Weak References & Breaking Reference Cycles
- Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs
- Learn Rust Series (#37) - Pin & Self-Referential Structs
- Learn Rust Series (#38) - PhantomData, Zero-Sized Types & Marker Lifetimes
- Learn Rust Series (#39) - Variance: Covariance, Contravariance & Why It Matters
- Learn Rust Series (#40) - Arena & Bump Allocation Patterns
- Learn Rust Series (#41) - Building Your Own Smart Pointer
- Learn Rust Series (#42) - Drop Order, the Drop Check & Leak Safety
- Learn Rust Series (#43) - std::mem: swap, replace, take & forget
- Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision
- Learn Rust Series (#45) - Mini Project: A Doubly-Linked List, Safe then Unsafe
- Learn Rust Series (#46) - Result Combinators: map, map_err, and_then, ok_or
- Learn Rust Series (#47) - Option Combinators & Null-Free Programming
- Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait
- Learn Rust Series (#49) - thiserror: Ergonomic Library Errors
- Learn Rust Series (#50) - anyhow: Flexible Application-Level Errors & Context
- Learn Rust Series (#51) - Panics, Unwinding, abort, and catch_unwind
- Learn Rust Series (#52) - Testing: Unit Tests, Integration Tests, and Doctests
- Learn Rust Series (#53) - Property-Based Testing with proptest
- Learn Rust Series (#54) - Fuzzing with cargo-fuzz and libFuzzer
- Learn Rust Series (#55) - Benchmarking with Criterion and Reading the Numbers
- Learn Rust Series (#56) - Cargo Workspaces and Multi-Crate Projects
- Learn Rust Series (#57) - Feature Flags and Conditional Compilation (cfg)
- Learn Rust Series (#58) - Build Scripts (build.rs) and Generating Code at Build Time
- Learn Rust Series (#59) - Clippy, rustfmt, and Writing Idiomatic Rust
- Learn Rust Series (#60) - Mini Project: A Fully Tested, Documented, Published-Ready CSV Toolkit Crate
- Learn Rust Series (#61) - Send and Sync: The Traits Behind Fearless Concurrency
- Learn Rust Series (#62) - Scoped Threads: Borrowing Local Data Across Threads (this post)
Learn Rust Series (#62) - Scoped Threads: Borrowing Local Data Across Threads
Last episode we met Send and Sync, the two traits that make Rust's concurrency fearless, and we leaned hard on Arc to share owned data with threads. Arc is exactly the right tool when a thread might outlive the function that started it -- a background worker, a request handler, a task queue. But here is the thing: an awful lot of parallelism is not like that at all. Very often you just want to fan out some work over a local Vec, wait for all of it to finish, and then carry straight on with the results. Cloning every last byte into an Arc (with its atomic reference count) for that is a bit like renting a moving truck to carry a single grocery bag across the kitchen. It works, but it is heavier than the job deserves.
std::thread::scope, stabilised in Rust 1.63 (mid-2022), is the tool for that job. It creates a scope inside which spawned threads may borrow local stack data directly -- no Arc, no clone, no 'static gymnastics -- because the scope makes one iron-clad promise to the compiler: every thread spawned inside it is joined before the scope returns. If the threads are guaranteed to finish first, the borrows they hold cannot possibly dangle, and the borrow checker is satisfied. This is structured concurrency, and it is one of the most quietly delightful features in the whole standard library ;-)
But first, as always, last episode's homework.
Solutions to Episode 61 Exercises
Episode 61 was Send and Sync, and the three exercises were all about proving to yourself, in code, which types cross a thread boundary and which do not.
Exercise 1 asked you to spawn a thread that takes ownership of a String, returns its length through join, and then to also try using the String back in main and read the resulting compile error. The point is to feel the move in your fingers:
use std::thread;
fn main() {
let name = String::from("fearless");
let handle = thread::spawn(move || name.len()); // `name` is MOVED into the thread
// println!("still here: {name}"); // ERROR: borrow of moved value: `name`
let len = handle.join().unwrap();
println!("length was {len}"); // length was 8
}
The move keyword transfers ownership of name into the closure, which is exactly what thread::spawn demands (the closure must be Send + 'static). Uncomment that middle line and the compiler stops you cold: name no longer lives in main, it lives in the other thread. Ownership went one way, and there is no coming back. Hold that feeling -- the entire episode today is about the tool that lets you not pay this price.
Exercise 2 wanted a Vec<i32> shared across four threads with Arc, each thread reading and doubling a different index, and the four results collected back into a Vec in the main thread:
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(vec![5, 6, 7, 8]);
let mut handles = Vec::new();
for i in 0..4 {
let d = Arc::clone(&data); // cheap: just bumps an atomic count
handles.push(thread::spawn(move || d[i] * 2));
}
let doubled: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
println!("{doubled:?}"); // [10, 12, 14, 16]
}
Each iteration clones the Arc (which only bumps the atomic reference counter -- the underlying Vec is never copied) and moves that clone into its own thread. All four threads read the same buffer at once, which is sound because reading with no writer is precisely what Sync guarantees. Keep this shape in mind, because in a few minutes we are going to write the same fan-out with thread::scope and watch every single Arc::clone disappear.
Exercise 3 was the little assert_send/assert_sync trick: write the two generic helpers, prove Arc<i32> satisfies both, and add a commented-out line showing that Rc<i32> does not:
use std::rc::Rc;
use std::sync::Arc;
fn assert_send<T: Send>(_: &T) {}
fn assert_sync<T: Sync>(_: &T) {}
fn main() {
let shared = Arc::new(1);
assert_send(&shared); // Arc is Send
assert_sync(&shared); // Arc is Sync
let local = Rc::new(1);
let _ = &local; // used, so no dead-code warning
// assert_sync(&local); // would NOT compile: Rc is not Sync (its count is non-atomic)
println!("Arc is Send + Sync; Rc is neither");
}
These helpers cost nothing at runtime (the compiler erases them entirely) yet they let you assert a type's thread-safety right in a test. Uncomment the assert_sync(&local) line and you get a compile error, because Rc's reference count is a plain non-atomic integer and two threads racing on it would corrupt it. Right, homework cleared. Now, scoped threads.
The problem: spawn demands 'static
Let us start where the pain is, because that is where the motivation lives. A plain thread::spawn closure must be 'static. That word means the closure is not allowed to borrow anything that lives in the calling function -- it may only hold data it owns outright or data that lives for the entire program. The reason is unavoidable: thread::spawn returns immediately, the new thread runs independently, and the compiler has absolutely no way to know whether that thread will finish before or after the current function returns and drops its local variables. So it assumes the worst and forbids the borrow.
Which means the most natural thing you could possibly want to write is a compile error:
use std::thread;
fn main() {
let numbers = vec![1, 2, 3];
thread::spawn(|| println!("{numbers:?}")); // ERROR: closure may outlive `numbers`, which it borrows
} // <- if the thread were still running here, `numbers` would already be dropped: a dangling reference
The compiler's message is admirably blunt: the closure may outlive numbers. And it is right to worry, because nothing in this code joins the thread. If main returned while the thread was still printing, it would be reading a Vec that had already been freed -- a use-after-free, the exact bug Rust exists to make impossible.
You have two classic escape hatches. Add move and give the thread its own copy or ownership of numbers; or wrap the data in an Arc and clone a handle in. Both work, and for genuinely long-lived threads both are correct. But when all you wanted was to briefly borrow a local Vec, run some parallel work over it, and get on with your day, being forced to hand over ownership or spin up an atomically-refcounted allocation feels like paying a tax on a transaction that should have been free. That tax is what thread::scope abolishes.
The solution: thread::scope
thread::scope takes a closure, and that closure receives a single argument: a scope handle, conventionally called s. Any thread you spawn through s.spawn is allowed to borrow data from the enclosing function, because thread::scope will not return until every one of those threads has been joined. The guarantee flips the compiler's reasoning on its head. Before, it could not prove the thread finished in time; now the scope forces it to, so the borrow is provably sound:
use std::thread;
fn main() {
let numbers = vec![1, 2, 3, 4];
thread::scope(|s| {
s.spawn(|| {
// borrowing `numbers` by reference is fine here: the scope joins
// every spawned thread before it returns, so this borrow cannot dangle
println!("sum is {}", numbers.iter().sum::<i32>()); // sum is 10
});
}); // <- thread::scope blocks HERE until all spawned threads have finished
println!("still have {} numbers", numbers.len()); // 4 -- numbers was only borrowed, never moved
}
Look at what did not happen. We did not write move. We did not clone anything into an Arc. And crucially, after the scope closes, numbers is still fully ours -- the last line reads its length without complaint, because the thread only ever held a shared & reference, and that reference is guaranteed to be gone before numbers is. No ownership changed hands. The borrow checker followed the whole story and signed off.
Notice too that you do not have to manually call join on that spawned thread. The scope does it for you at its closing brace. Structured concurrency means the threads' lifetimes are nested cleanly inside the scope's, the way a function's locals are nested inside the function -- everything that opens is closed, in order, automatically.
Scoped threads return values
Just like thread::spawn, a call to s.spawn returns a join handle, and calling join on it yields whatever the thread's closure returned (wrapped in a Result, because the thread might have panicked). Because the scope closure is itself just a closure, it can return a value, so you can collect results inside the scope and hand the combined answer back out:
use std::thread;
fn main() {
let data = vec![10, 20, 30, 40];
let (left_sum, right_sum) = thread::scope(|s| {
let left = s.spawn(|| data[..2].iter().sum::<i32>()); // borrows data
let right = s.spawn(|| data[2..].iter().sum::<i32>()); // borrows data too
(left.join().unwrap(), right.join().unwrap())
});
println!("halves: {left_sum} and {right_sum}"); // halves: 30 and 70
}
Two threads borrow data immutably at the same time, and that is completely fine -- shared reads never conflict, which is the whole reason &T can be handed to many threads at once (that is Sync, from last episode, doing its job). We split the work in half, ran both halves in parallel, and folded the two answers into a tuple that becomes the value of the entire thread::scope expression. Clean as a whistle.
One thread per item
Because borrowing is now essentially free, spawning one thread per work item over a borrowed collection becomes a completely natural thing to write. Here each thread borrows a single word -- a &str slice pointing right into the original String -- with no copying whatsoever:
use std::thread;
fn main() {
let text = String::from("the quick brown fox");
thread::scope(|s| {
for word in text.split_whitespace() {
// each thread borrows a &str slice that points INTO `text`
s.spawn(move || println!("word has {} letters: {word}", word.len()));
}
});
// all four threads have finished by the time we reach this line
println!("original text is untouched: {text}");
}
The move on the inner closure here is not moving text -- it is moving the individual &str slice word (a reference, which is Copy-cheap to move) into each thread. The big String itself is only ever borrowed. In stead of copying substrings around, every thread points directly at the bytes it cares about inside the one buffer, and the scope guarantees they all finish before text goes anywhere.
Parallel mutation of disjoint parts
Shared reads are the easy case. The genuinely interesting one -- the thing that makes people's eyes light up when they first see it -- is mutating data in parallel with no lock at all. The key that unlocks it is split_at_mut, which splits a mutable slice into two non-overlapping mutable halves. Because the two halves provably cannot alias (they cover disjoint index ranges), Rust is happy to let two threads each take one half and write to it simultaneously:
use std::thread;
fn main() {
let mut data = [1, 2, 3, 4, 5, 6];
let (left, right) = data.split_at_mut(3); // two &mut halves that cannot overlap
thread::scope(|s| {
s.spawn(|| { for x in left.iter_mut() { *x *= 10; } }); // thread A owns the left half
s.spawn(|| { for x in right.iter_mut() { *x += 100; } }); // thread B owns the right half
});
println!("{data:?}"); // [10, 20, 30, 104, 105, 106]
}
This is data parallelism at its absolute purest. There is no Mutex, no atomic, no synchronisation of any kind -- and there does not need to be, because the borrow checker has proven that thread A and thread B can never touch the same element. An entire category of bug (two threads writing the same slot and clobbering each other) is not "unlikely" or "guarded against"; it is impossible to express. That is a fundamentally different kind of safety from "we added a lock and hope we did not forget one". split_at_mut is the workhorse behind most real parallel-array code in Rust, and scoped threads are what let you spend those disjoint borrows across threads.
Collecting results across a borrowed collection
Now let us bring it full circle and rewrite that Arc-heavy fan-out from Exercise 2 the scoped way. We borrow a Vec, spawn a thread per element, and sum the results -- without a single Arc, clone, or move of the underlying data:
use std::thread;
fn main() {
let config = vec!["host", "port", "user", "password"];
let total: usize = thread::scope(|s| {
let handles: Vec<_> = config.iter()
.map(|item| s.spawn(move || item.len())) // each closure borrows one &&str
.collect();
handles.into_iter().map(|h| h.join().unwrap()).sum()
});
println!("total length of all config keys: {total}"); // 20
}
Compare this to the Exercise 2 solution above. There we had Arc::new, a manual Vec of handles, and an Arc::clone on every iteration. Here config is borrowed once, the closures reach straight into it, and the scope guarantees everything is joined before config could be dropped. The Arc was doing real work in the earlier example -- it kept the data alive for threads that might outlive main -- but here, where the parallelism is bounded by the current function, it was pure overhead. Scoped threads let you delete it and let the borrow checker prove the result is still correct.
A slightly bigger example: min and max in one pass
To show this on something with a touch more shape, here is a parallel scan that finds the minimum and maximum of a borrowed slice at the same time, each on its own thread:
use std::thread;
fn parallel_min_max(values: &[i64]) -> (i64, i64) {
thread::scope(|s| {
let min_handle = s.spawn(|| *values.iter().min().unwrap());
let max_handle = s.spawn(|| *values.iter().max().unwrap());
(min_handle.join().unwrap(), max_handle.join().unwrap())
})
}
fn main() {
let readings = vec![42, -7, 100, 3, -55, 88, 17];
let (lo, hi) = parallel_min_max(&readings);
println!("min = {lo}, max = {hi}"); // min = -55, max = 100
println!("readings still usable: {} values", readings.len()); // 7
}
The parallel_min_max function takes an ordinary &[i64] -- no lifetimes to annotate, no Arc<[i64]> in the signature, just a plain borrowed slice like any other function would accept. Inside, two threads scan the same data concurrently, one hunting the minimum and one the maximum, and the whole thread::scope expression evaluates to the tuple we return. From the caller's side there is no visible concurrency machinery at all; it looks like any other function that returns a pair. That is the ergonomic win: parallelism that reads like ordinary borrowing code.
When scope is the right tool, and when it is not
Having said that, thread::scope is not a universal replacement for Arc and spawn -- the two solve different problems, and knowing which is which is the actual skill here. Scoped threads are for parallelism that is bounded by the current function: fan out, do the work, join, return. The moment a thread must genuinely outlive the function that started it -- a background logger that runs for the life of the program, a worker sitting on a channel waiting for jobs, a connection handler in a server -- a scope cannot help you, because a scope's entire premise is that it blocks until its threads finish. You cannot "escape" a scope with a still-running thread; that is the whole point of it. For those long-lived cases you are back to move, ownership, and Arc for anything shared.
So the decision is genuinely simple. Is the parallel work finished by the time this function returns? Use thread::scope and borrow freely. Does a thread need to keep living after this function is gone? Give it ownership (move) and reach for Arc when it must share. In my experience the first case is far more common than beginners expect -- a great deal of everyday parallelism is exactly "chop this data up, process the pieces, collect the answers" -- which is why it is a small tragedy that so much Rust code reaches for Arc<Mutex<...>> reflexively when a seven-line scope would have been simpler, faster, and clearer.
How Python and Go would frame this
A glance sideways sharpens the contrast, as it usually does. In Python, threads share the enclosing scope's variables by default -- there is no borrow checker, so a nested function simply closes over whatever names are in scope, and you are trusted to not let a thread outlive the data it reads:
from concurrent.futures import ThreadPoolExecutor
def parallel_min_max(values):
with ThreadPoolExecutor() as pool: # the `with` block joins all tasks on exit
lo = pool.submit(min, values)
hi = pool.submit(max, values)
return lo.result(), hi.result() # nothing stops you touching `values` unsafely elsewhere
print(parallel_min_max([42, -7, 100, 3])) # (-7, 100)
The with ThreadPoolExecutor() block is actually a close cousin of thread::scope in spirit -- it joins its tasks when the block exits. But nothing in the language forces the data to stay valid, and (thanks to the GIL) this is not even giving you real CPU parallelism for pure-Python work. The structure is a convention you follow, not an invariant the compiler enforces.
Go makes the same trade with its famously light goroutines. A sync.WaitGroup joins them, but the compiler will cheerfully let a goroutine capture a loop variable or a slice that gets mutated out from under it:
func parallelMinMax(values []int) (int, int) {
var lo, hi int
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); lo = minOf(values) }() // captures `values` by reference
go func() { defer wg.Done(); hi = maxOf(values) }() // no compiler check that this is safe
wg.Wait() // join point -- if you remember it
return lo, hi
}
Go's goroutines borrowing the enclosing slice looks almost identical to Rust's scoped threads, and when you remember the wg.Wait() it behaves the same. The difference is the same one we saw last episode: in Go the join is a discipline you must maintain, and forgetting it (or racing on lo/hi) is a runtime bug the -race detector might catch on a good day. In Rust, thread::scope is the join -- you cannot forget it, because the borrows literally will not compile without it. The guarantee is structural, not behavioural.
Wrapping up
So there we have it: structured concurrency, standard-library style. thread::scope gives you a region in which spawned threads may borrow local stack data -- immutably by many threads at once, or mutably over disjoint slices via split_at_mut -- because the scope guarantees every thread is joined before it returns. That single guarantee is what turns "the borrow might dangle" into "the borrow provably cannot", which is why you get to drop the Arc, drop the clones, drop the move, and just borrow, exactly as you would in ordinary single-threaded code. Scoped threads return values through their handles, the scope collects them, and the whole thing reads like a normal function.
The one thing to keep straight is the boundary: scopes are for parallelism that finishes before the function returns. When a thread must outlive its spawner, you are back in Arc and move territory, and that is not a failure of thread::scope -- it is a different job. Learn to spot which one you are looking at, and a surprising amount of "obviously needs shared ownership" code turns out to need nothing more than a seven-line scope.
From here, Phase 5 keeps building on this foundation. We have shared data by borrowing it and by owning it; next we start moving data between threads as they run, rather than only at the join, and that opens up a whole different style of concurrent design. That is where we are headed ;-)
Exercises
- Use
thread::scopeto sum two halves of a stack array[i64; 8]on two separate threads in parallel, then add the two partial sums in the main thread and print the total. - Use
split_at_mutwith two scoped threads to negate every element in the first half of a slice and square every element in the second half, then print the mutated slice. - Spawn one scoped thread per line of a borrowed multi-line
String(use.lines()), have each thread compute and return its line's length, collect the lengths into aVec, and print it.
Thanks for reading, and happy threading! ;-)