Spawning Threads in Rust With std thread and Joining Them
Start OS threads with std::thread::spawn, move data in with move closures, collect results with join and borrow safely with scoped threads.

The thread you spawn can outlive the function that spawned it, and the compiler takes that seriously enough to refuse the first program most people write. std::thread::spawn is a four-line API, but it hides three ideas: the closure must own what it uses, the join handle carries the thread's result or its panic, and "the main thread exited" quietly kills every thread still running. This page walks through each with code that compiles on Rust 1.99 with edition 2024 and nothing but the standard library.
Spawn, then join
spawn takes a closure, starts an OS thread and returns a JoinHandle<T>, where T is whatever the closure returns. Calling join() blocks until the thread finishes and gives you that value.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
let sum: u64 = (1..=1_000_000).sum();
sum
});
println!("main keeps working while the thread runs");
let sum = handle.join().expect("worker panicked");
println!("sum = {sum}");
}main keeps working while the thread runs
sum = 500000500000
The first line is printed by the main thread, usually before the worker has finished, though nothing guarantees that ordering. What is guaranteed is that join returns only after the closure has completed, so sum is final. The expect is there because join returns a Result, and the error case is a panic in the worker, which we come back to below.
The first compile error: the closure borrows
Now use a local variable in the closure without moving it.
// does not compile
use std::thread;
fn main() {
let names = vec!["ada", "grace", "linus"];
let handle = thread::spawn(|| {
println!("{}", names.len());
});
handle.join().unwrap();
}error[E0373]: closure may outlive the current function, but it borrows `names`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `names`
7 | println!("{}", names.len());
| ----- `names` is borrowed here
|
note: function requires argument type to outlive `'static`
help: to force the closure to take ownership of `names` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ++++The compiler cannot prove that main will outlive the thread, and for spawn it never can, because the thread may keep running after the function returns and names is gone. The signature requires the closure to be 'static, which means it may not borrow anything local. The help text is correct: add move so the closure takes ownership.
use std::thread;
fn main() {
let names = vec!["ada", "grace", "linus"];
let handle = thread::spawn(move || {
println!("worker sees {} names", names.len());
names
});
let back = handle.join().unwrap();
println!("got the vector back: {back:?}");
}worker sees 3 names
got the vector back: ["ada", "grace", "linus"]
After a move, main can no longer use names, which is why the closure returns it and we receive it from join. That round trip is the standard way to get ownership back. If two threads need the same data, you either clone it for each, share it behind an Arc (Arc and Mutex), or use scoped threads.
Scoped threads borrow safely
Since Rust 1.63, std::thread::scope lets threads borrow local data, because the scope guarantees every thread inside it is joined before the function continues. No move, no Arc, no clone.
use std::thread;
fn main() {
let numbers: Vec<u64> = (1..=8).collect();
let (left, right) = numbers.split_at(numbers.len() / 2);
let (a, b) = thread::scope(|s| {
let a = s.spawn(|| left.iter().sum::<u64>());
let b = s.spawn(|| right.iter().sum::<u64>());
(a.join().unwrap(), b.join().unwrap())
});
println!("left = {a}, right = {b}, total = {}", a + b);
}left = 10, right = 26, total = 36
The two threads borrow slices of the same vector. Because left and right are disjoint and read-only, the borrow checker is satisfied, and the scope ends only when both are done. For splitting an array across threads this is the cleanest tool in the standard library, and it is the pattern a thread pool such as rayon automates (parallel iterators with Rayon).
What a panic in a thread does
A panic stops the thread that panicked, not the process. The panic message prints to stderr and join returns Err carrying the payload.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
let divisor = std::env::args().count() - 1; // 0 when no arguments are given
100 / divisor
});
match handle.join() {
Ok(v) => println!("result {v}"),
Err(payload) => {
let msg = payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".into());
println!("worker failed: {msg}");
}
}
}thread '<unnamed>' (27419042) panicked at src/main.rs:6:9:
attempt to divide by zero
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
worker failed: attempt to divide by zero
The number in parentheses is the operating system's thread id and changes every run; the message goes to stderr, so it can appear before or after the line from println! depending on how your terminal interleaves the two streams.
The payload is a Box<dyn Any + Send>, and the message is either a &str (for a literal panic) or a String (for a formatted one), so you try both. Notice the thread name is <unnamed>. In a program with several workers that line is not much help, which is what thread::Builder fixes.
Name the thread and size its stack
use std::thread;
fn main() -> std::io::Result<()> {
let handle = thread::Builder::new()
.name("indexer-1".to_string())
.stack_size(4 * 1024 * 1024)
.spawn(|| {
let me = thread::current();
println!("running as {:?}", me.name());
})?;
handle.join().unwrap();
Ok(())
}running as Some("indexer-1")
Builder::spawn returns an io::Result, which is the thing plain thread::spawn hides: if the operating system refuses to create a thread, spawn panics, while Builder lets you handle it. The name shows up in panic messages, in top -H and in profilers. The default stack for a spawned thread is 2 MiB; deep recursion is the usual reason to raise it.
Many threads, results in order
Spawn in a loop, keep the handles in a vector, and join them in the same order. The results line up with the inputs regardless of which thread finishes first.
use std::thread;
fn is_prime(n: u64) -> bool {
n > 1 && (2..).take_while(|d| d * d <= n).all(|d| n % d != 0)
}
fn main() {
let ranges = [(1, 25_000), (25_001, 50_000), (50_001, 75_000), (75_001, 100_000)];
let handles: Vec<_> = ranges
.into_iter()
.map(|(lo, hi)| thread::spawn(move || (lo..=hi).filter(|&n| is_prime(n)).count()))
.collect();
let counts: Vec<usize> = handles.into_iter().map(|h| h.join().unwrap()).collect();
println!("primes per range: {counts:?}");
println!("total below 100,000: {}", counts.iter().sum::<usize>());
}primes per range: [2762, 2371, 2260, 2199]
total below 100,000: 9592
9,592 is the known count of primes below 100,000, a useful check that splitting the range lost nothing. The collect() into a Vec before joining matters: a lazy iterator that spawns and joins one thread at a time would run them serially.
Does it actually go faster?
Threads are only worth the trouble if the work splits. We counted the primes below 3,000,000 with 1, 2, 4 and 8 threads on a 10-core machine, release build. Each thread takes the numbers whose remainder modulo the thread count matches its index.
use std::thread;
use std::time::Instant;
fn is_prime(n: u64) -> bool {
n > 1 && (2..).take_while(|d| d * d <= n).all(|d| n % d != 0)
}
fn count_primes(limit: u64, threads: u64) -> usize {
let handles: Vec<_> = (0..threads)
.map(|t| {
thread::spawn(move || {
(1..=limit).filter(|n| n % threads == t).filter(|&n| is_prime(n)).count()
})
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).sum()
}
fn main() {
let limit = 3_000_000;
for threads in [1, 2, 4, 8] {
let start = Instant::now();
let n = count_primes(limit, threads);
println!("{threads} threads: {n} primes in {:>7.1?}", start.elapsed());
}
}1 threads: 216816 primes in 313.4ms
2 threads: 216816 primes in 271.9ms
4 threads: 216816 primes in 149.0ms
8 threads: 216816 primes in 77.6ms
We expected two threads to take about half the time, and they took 87% of it. The split is the reason. With two threads, one gets every even number, which is_prime rejects after one division, and the other gets every odd number, which is where all the real work is. The slow thread sets the finish time. With 4 and 8 threads the odd numbers spread across several of them, and the speedup reaches 4.0x at 8 threads. The lesson is that equal-sized chunks of input are not equal-sized chunks of work, and a work-stealing pool avoids the problem by letting idle threads take over.
Forgetting to join
If main returns while a thread is still running, the process exits and the thread is killed mid-work, silently. A spawned thread is not a daemon you can leave behind and expect to finish. Either keep the handle and join it, or use a channel so the worker reports completion (message passing with mpsc).
What dropping a JoinHandle does
If you drop a JoinHandle without joining, the thread is detached: it keeps running on its own and nobody can collect its result. That is occasionally what you want for a fire-and-forget logger, and it is the source of the "my output is missing" bug when the thread is still working as main returns. There is no way to kill a thread from outside in safe Rust, so a long-running worker needs a flag it checks (an AtomicBool) or a channel it listens on to learn that it should stop.
When plain threads are the wrong tool
- Thousands of concurrent waits, such as network connections. Each OS thread costs a stack and a scheduler entry; use async tasks (async Rust and Tokio).
- A fixed amount of CPU work spread across cores. A pool with work stealing handles uneven chunks better than a hand-split range.
- Shared mutable state with frequent writes. Threads are easy to start and easy to make slower than one thread; the type rules that decide what may cross are in Send and Sync.


