Threads and join handles
A Rust program starts with one thread and creates more with std::thread::spawn. Each call returns a JoinHandle, and the handle is both the waiting point and the result: joining a thread returns whatever its closure returned. There is no thread object to destroy and no start call.
Coming from C++? Threads and data races starts a thread in both languages, shows the capture that one compiler accepts, and prints the race detector's verdict.
This lesson covers the handle, the ownership the closure needs, several threads at once, and a worker that panics without stopping the program. The last section is the compiler refusing a closure that borrows a local, which is the first error every new Rust thread produces.
A thread and its handle
spawn takes a closure, runs it on a new thread and returns at once. The closure returns a value, and join blocks the calling thread until the worker is done and hands the value back inside a Result, because the worker may have panicked.
The program multiplies on the worker and prints the number from the main thread. Nothing is shared and nothing is printed twice, so the output is the same on every run.
use std::thread;
fn main() {
let handle = thread::spawn(|| 21 * 2);
let value = handle.join().unwrap();
println!("{value}");
}
rustc --edition 2024 join.rs
./join
42
The unwrap here unwraps the join result, not the program logic, and it is safe because this closure cannot panic. A program that joins without unwrapping handles the panic case explicitly, which the fourth section does. The lesson on functions explains why the closure returns a value at all.
A closure that owns its data
A spawned thread may outlive the function that started it, so everything the closure touches has to be owned by the closure or borrowed for the whole program. The move keyword makes the closure take ownership of the names it mentions.
The program builds a string, moves it into the worker and prints the formatted result. After the spawn the main thread no longer has the string, which the compiler enforces.
use std::thread;
fn main() {
let name = String::from("worker");
let handle = thread::spawn(move || format!("{name} done"));
println!("{}", handle.join().unwrap());
}
rustc --edition 2024 moved.rs
./moved
worker done
The string is dropped by the worker when its closure ends, which is later than the spawn and earlier than the end of main, and the ownership rules make that safe without a lock. The lesson on ownership is the same move without a thread.
Several threads
A vector of handles is the shape for a batch of work: the loop spawns, the collection joins. The results come back in the order of the joins, which is the order of the vector, not the order the threads finished, so the output is reproducible even though the execution is not.
The program squares three indices on three threads and sorts the results before printing them, so even a reordered join list would print the same line.
use std::thread;
fn main() {
let mut handles = Vec::new();
for index in 0..3 {
handles.push(thread::spawn(move || index * index));
}
let mut values: Vec<i32> = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect();
values.sort();
println!("{values:?}");
}
rustc --edition 2024 several.rs
./several
[0, 1, 4]
move is what lets the loop variable travel into each closure: without it every closure would borrow the same index, which does not live long enough. The lesson on closures covers the capture rules in full.
A thread that panics
A panic in a worker unwinds that thread only. The runtime prints the message, the thread ends, and the join call returns Err with the payload, so the main thread can decide whether to continue, retry or exit.
The program starts a worker that panics and prints the two facts it can still see: the join failed and main is still alive. The panic message on the standard error stream is part of the output shown below.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
panic!("worker failed");
});
println!("{}", handle.join().is_err());
println!("main still runs");
}
rustc --edition 2024 panic.rs
./panic
thread '<unnamed>' (205) panicked at panic.rs:5:9:
worker failed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
true
main still runs
The number in parentheses is the thread id and changes on every run, which is why it is not something a program reads. The exit status is still zero because main returned normally, and the lesson on panics shows the case where the failure reaches the top and becomes a status of 101.
A closure that borrows
Without move the closure borrows the local, and a spawned thread may outlive it. The compiler reports E0373, names the borrowed value and suggests the keyword, which is the fix in one line.
The program passes a string to the worker by reference. The compiler stops at the closure and points at the variable that would dangle.
use std::thread;
fn main() {
let name = String::from("worker");
let handle = thread::spawn(|| {
println!("{name}");
});
handle.join().unwrap();
}
rustc --edition 2024 borrow_err.rs
error[E0373]: closure may outlive the current function, but it borrows `name`, which is owned by the current function
--> borrow_err.rs:5:32
|
5 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `name`
6 | println!("{name}");
| ---- `name` is borrowed here
|
note: function requires argument type to outlive `'static`
--> borrow_err.rs:5:18
|
5 | let handle = thread::spawn(|| {
| __________________^
6 | | println!("{name}");
7 | | });
| |______^
help: to force the closure to take ownership of `name` (and any other referenced variables), use the `move` keyword
|
5 | let handle = thread::spawn(move || {
| ++++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0373`.
The note about 'static is the reason: a thread the program does not join has no lifetime relation to the function that started it, so the closure must own everything it reads. The lesson on scoped threads removes the requirement for a scope that guarantees the join.
Exercises
- Start two threads, join both and print the sum of their results.
- Move a vector into a worker, return its length and check that the main thread can no longer use it.
- Make a worker panic and turn the failed join into a message instead of an unwrap.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does
thread::spawnreturn?Answer
A JoinHandle with the result of the closure
The handle waits for the thread and carries its return value.
-
Why does a spawned closure usually need
move?Answer
The thread may outlive the function, so it must own what it reads
A borrow from the starting function would dangle once that function returns.
-
What does
joinreturn?Answer
A Result with the value or the panic payload
The worker may have panicked, so the value arrives inside a Result.
-
What happens to the program when a worker panics?
Answer
Only that thread ends, and join reports the failure
An unwinding panic stays inside the thread and reaches the caller through join.
-
What does this program print?
use std::thread; fn main() { let handle = thread::spawn(|| 6 * 7); println!("{}", handle.join().unwrap()); }Answer
42
The closure returns forty-two and join hands it back.
-
What does this program print?
use std::thread; fn main() { let text = String::from("abc"); let handle = thread::spawn(move || text.len()); println!("{}", handle.join().unwrap()); }Answer
3
The string is three bytes long and the worker returns the length.
-
What does this program print?
use std::thread; fn main() { let handles: Vec<_> = (1..=3).map(|value| thread::spawn(move || value * 10)).collect(); let mut values: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect(); values.sort(); println!("{values:?}"); }Answer
[10, 20, 30]
Each worker multiplies its value by ten, and the sort fixes the order of the print.
-
What does this program print?
use std::thread; fn main() { let handle = thread::spawn(|| { panic!("boom"); }); println!("{}", handle.join().is_err()); }Answer
true
The worker panics, and join reports it as an error while main continues.
-
What is the first line of the compiler output for this program?
use std::thread; fn main() { let name = String::from("worker"); let handle = thread::spawn(|| { println!("{name}"); }); handle.join().unwrap(); }Answer
error[E0373]: closure may outlive the current function, but it borrows
name, which is owned by the current functionThe closure borrows a local that the thread may outlive, and move is the fix.
-
What is the first line of the compiler output for this program?
use std::thread; fn main() { let handle = thread::spawn(|| 1); handle.join(); println!("{}", handle.join().unwrap()); }Answer
error[E0382]: use of moved value:
handlejoin takes the handle by value, so the second call uses a moved binding.
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