Scoped threads

A plain spawn needs a 'static closure, because the thread may outlive the function that started it. Most programs join their threads before they return, and for those a scope says so: thread::scope runs a closure, every thread started in it is joined when the closure ends, and until then the threads may borrow the locals around them.

This lesson shares a local with one thread, splits a vector between several, shows the join that happens without a call, and panics inside a scope. The last section is the compiler refusing a handle that tries to leave the scope it belongs to.

A borrowed value in a scope

Inside the closure, scope.spawn takes a closure that may borrow anything the scope can see. The value does not move and no reference count is needed, because the scope cannot end before the thread does.

The program gives a thread a shared reference to a string and joins it inside the scope. The length comes back and the string is still owned by main.

use std::thread; fn main() { let text = String::from("scoped"); let length = thread::scope(|scope| scope.spawn(|| text.len()).join().unwrap()); println!("{length}"); }

rustc --edition 2024 borrow.rs
./borrow

6

The closure passed to scope returns the length, which is the value of the whole expression. Without the scope the same closure would be refused with E0373, the error of the threads lesson.

Several threads over slices

Vec::chunks gives disjoint slices of one vector, and disjoint means several threads may hold them at once. The scope makes that legal without an Arc and without copying the data.

The program splits four numbers into two pairs, sums each pair on its own thread and collects the two sums in the order the handles were made.

use std::thread; fn main() { let values = vec![1, 2, 3, 4]; let sums: Vec<i32> = thread::scope(|scope| { let mut handles = Vec::new(); for chunk in values.chunks(2) { handles.push(scope.spawn(move || chunk.iter().sum::<i32>())); } handles.into_iter().map(|handle| handle.join().unwrap()).collect() }); println!("{sums:?}"); }

rustc --edition 2024 chunks.rs
./chunks

[3, 7]

The move on the inner closure moves the slice and not the vector: a slice is a pointer and a length, and the borrow behind it still points into values, which the scope keeps alive. The lesson on slices is the type itself.

The scope joins for you

A thread started in a scope that nobody joins is joined when the scope ends, and a panic in it is passed on. That is why a plain local can be shared by reference: the scope waits for every thread before it returns.

Three threads increment an atomic counter that is a local variable. The print after the scope reads three, and no join call appears in the program.

use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; fn main() { let counter = AtomicUsize::new(0); thread::scope(|scope| { for _ in 0..3 { scope.spawn(|| { counter.fetch_add(1, Ordering::Relaxed); }); } }); println!("{}", counter.load(Ordering::Relaxed)); }

rustc --edition 2024 counter.rs
./counter

3

The counter is not an Arc and not a static, and the atomic type is what makes the sharing legal. The scope replaces the Arc of the mutex lesson whenever the threads end with the function, which is the usual case.

A handle that nobody joins is joined by the scope anyway, so a program that only cares about the side effects can drop the handle and write scope.spawn(...); as a statement. The scope still waits, and a panic in that thread still fails the whole scope, which is the difference from detaching.

A named thread is easier to find in a backtrace, and the name is the one a panic message prints instead of the anonymous number.

A panic inside the scope

A scoped thread can panic like any other, and its ScopedJoinHandle reports the failure the same way. If nobody joins it, the scope itself panics when it ends, which is the behaviour a caller cannot ignore.

The program starts a thread that panics and joins it inside the scope, so the failure is turned into a boolean and the program continues.

use std::thread; fn main() { let result = thread::scope(|scope| { let handle = scope.spawn(|| panic!("scoped failure")); handle.join().is_err() }); println!("{result}"); }

rustc --edition 2024 panic.rs
./panic


thread '<unnamed>' (2266) panicked at panic.rs:5:37:
scoped failure
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
true

The thread id changes on every run. Remove the join and the same panic is re-raised when the scope ends, which turns the whole thread::scope call into a panic site. The lesson on panics follows the failure past this point.

The handle escapes

A ScopedJoinHandle borrows the scope, so it cannot leave the closure that owns the scope. Returning it is a lifetime error, and the compiler names the two lifetimes that disagree.

The program makes a handle inside the scope and joins it outside. The closure's return type carries the scope lifetime, and the error says so.

use std::thread; fn main() { let text = String::from("scoped"); let handle = thread::scope(|scope| scope.spawn(|| text.len())); println!("{}", handle.join().unwrap()); }

rustc --edition 2024 handle_err.rs

error: lifetime may not live long enough
--> handle_err.rs:5:40
|
5 | let handle = thread::scope(|scope| scope.spawn(|| text.len()));
| ------ ^^^^^^^^^^^^^^^^^^^^^^^^^^ returning this value requires that `'1` must outlive `'2`
| | |
| | return type of closure is ScopedJoinHandle<'2, usize>
| has type `&'1 Scope<'1, '_>`
|
help: consider adding 'move' keyword before the nested closure
|
5 | let handle = thread::scope(|scope| scope.spawn(move || text.len()));
| ++++

error: aborting due to 1 previous error

The suggestion is a different program, not the fix: it moves the string into the closure and still returns a handle that borrows the scope. The compile error is the point, because a handle that outlived the scope would point at threads that no longer exist. The next lesson keeps threads alive on purpose and needs the Arc the scope lets this one avoid.

Exercises

  1. Sum a vector in four scoped threads and compare the total with the sequential sum.
  2. Share a Mutex<Vec<i32>> with three scoped threads and push from each.
  3. Panic in one scoped thread without joining it and read the message the scope prints.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does thread::scope guarantee?

    Answer

    Every thread started in it is joined before it returns

    The scope waits for all of its threads, which is what allows the borrows.

  2. Why does a scoped thread not need Arc?

    Answer

    It may borrow the value because the scope outlives the thread

    The join before the end of the scope makes a plain reference safe.

  3. What happens to an unjoined thread when the scope ends?

    Answer

    It is joined, and a panic in it is passed on

    The scope joins every thread it started and re-raises a failure.

  4. Why can a ScopedJoinHandle not leave the scope?

    Answer

    It borrows the scope, whose threads end with it

    A handle outside the scope would refer to threads that are already gone.

  5. What does this program print? use std::thread; fn main() { let value = 6; let result = thread::scope(|scope| scope.spawn(|| value * 7).join().unwrap()); println!("{result}"); }

    Answer

    42

    The scoped closure borrows the local, which the scope makes legal.

  6. What does this program print? use std::thread; fn main() { let values = vec![10, 20, 30]; let total: i32 = thread::scope(|scope| { let handles: Vec<_> = values.iter().map(|value| scope.spawn(move || value + 1)).collect(); handles.into_iter().map(|handle| handle.join().unwrap()).sum() }); println!("{total}"); }

    Answer

    63

    Each thread adds one to its value, and the three results add up to sixty-three.

  7. What does this program print? use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; fn main() { let counter = AtomicUsize::new(0); thread::scope(|scope| { for _ in 0..4 { scope.spawn(|| { counter.fetch_add(2, Ordering::Relaxed); }); } }); println!("{}", counter.load(Ordering::Relaxed)); }

    Answer

    8

    Four threads add two each, and the scope joins them before the print.

  8. What does this program print? use std::thread; fn main() { let text = String::from("abc"); let length = thread::scope(|scope| { let handle = scope.spawn(|| text.len()); handle.join().unwrap() }); println!("{length} {text}"); }

    Answer

    3 abc

    The thread only borrows the string, which is still available after the scope.

  9. What is the first line of the compiler output for this program? use std::thread; fn main() { let text = String::from("scoped"); let handle = thread::scope(|scope| scope.spawn(|| text.len())); println!("{}", handle.join().unwrap()); }

    Answer

    error: lifetime may not live long enough

    The handle borrows the scope and the return type would carry it outside.

  10. What is the first line of the compiler output for this program? use std::thread; fn main() { let value = 1; let handle = thread::spawn(|| value + 1); println!("{}", handle.join().unwrap()); }

    Answer

    error[E0373]: closure may outlive the current function, but it borrows value, which is owned by the current function

    A plain spawn has no scope, so the borrowed local is refused and move or scope is the answer.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev