Why this matters
Modern CPUs have many cores, and a program that uses only one leaves most of the machine idle. Threads let one program do several things at once: split a computation across cores, keep a user interface responsive while working, handle many network connections. Concurrency is also notoriously error-prone; data races and their heisenbugs have cost the industry dearly. Rust's ownership rules extend to threads, so the most common concurrency bugs become compile errors: this is what people call "fearless concurrency".
Processes, threads and the scheduler
A process is a running program with its own memory. A thread is a sequence of execution inside a process; all threads of a process share the same memory (the heap and global data), and each has its own stack. The operating system scheduler decides which threads run on which cores, switching between them many times per second. You do not control the interleaving, and it can differ on every run.
process memory (shared): code | static data | heap
thread 1: own stack, own registers -- runs on core 0
thread 2: own stack, own registers -- runs on core 1, or interleaved with thread 1
Spawning and joining
use std::thread;
fn main() {
let handle = thread::spawn(|| {
let mut sum = 0u64;
for i in 1..=1_000 {
sum += i;
}
sum
});
println!("main keeps working while the thread runs");
let result = handle.join().unwrap();
println!("thread computed {result}");
}
main keeps working while the thread runs
thread computed 500500
thread::spawn(closure)starts a new thread running the closure and returns aJoinHandle<T>, whereTis the closure's return type.handle.join()waits for the thread to finish and returnsResult<T, ...>:Ok(value), orErrif the thread panicked. A panic in a spawned thread does not crash the whole program; it is reported throughjoin.- When
mainreturns, the process exits and any threads still running are killed. Join the threads whose work matters.
Order is not guaranteed
use std::thread;
fn main() {
let mut handles = Vec::new();
for id in 1..=3 {
handles.push(thread::spawn(move || id * 100));
}
let results: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
println!("{:?}", results);
}
[100, 200, 300]
If each thread printed directly, the lines could come out in any order. Here the threads only compute; main joins them in spawn order and prints once, so the output is deterministic. This is a good general habit: let threads compute and let one place produce output.
move closures and ownership
A spawned thread can outlive the function that created it, so the compiler will not let its closure borrow local variables:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("{:?}", data);
});
handle.join().unwrap();
}
error[E0373]: closure may outlive the current function, but it borrows `data`, which is owned by the current function
--> main.rs:5:32
|
5 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `data`
6 | println!("{:?}", data);
| ---- `data` is borrowed here
|
note: function requires argument type to outlive `'static`
--> main.rs:5:18
|
5 | let handle = thread::spawn(|| {
| __________________^
6 | | println!("{:?}", data);
7 | | });
| |______^
help: to force the closure to take ownership of `data` (and any other referenced variables), use the `move` keyword
|
5 | let handle = thread::spawn(move || {
| ++++
E0373 (from module 11) again, for the same reason: data might be dropped while the thread still uses it. move transfers ownership into the thread:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
let total: i32 = data.iter().sum();
println!("thread owns {:?}, sum {total}", data);
});
handle.join().unwrap();
}
thread owns [1, 2, 3], sum 6
After the move, main can no longer use data; the ownership rules make it impossible for two threads to touch the same Vec unsynchronized. thread::spawn requires its closure to be 'static (own all its data, module 10) and Send (lesson 4).
Splitting work
A typical pattern: divide data into chunks, give each thread its own chunk, combine the results.
use std::thread;
fn is_prime(n: u64) -> bool {
n >= 2 && (2..).take_while(|d| d * d <= n).all(|d| n % d != 0)
}
fn main() {
let limit = 200_000u64;
let workers = 4;
let step = limit / workers;
let mut handles = Vec::new();
for w in 0..workers {
let start = w * step;
let end = if w == workers - 1 { limit } else { start + step };
handles.push(thread::spawn(move || (start..end).filter(|&n| is_prime(n)).count()));
}
let counts: Vec<usize> = handles.into_iter().map(|h| h.join().unwrap()).collect();
println!("per thread: {:?}", counts);
println!("primes below {limit}: {}", counts.iter().sum::<usize>());
}
per thread: [5133, 4459, 4256, 4136]
primes below 200000: 17984
Each thread gets plain numbers (start, end, which are Copy and moved into the closure) and returns a count. No data is shared, so nothing needs synchronizing. On a 4-core machine this runs close to four times faster than one thread. (Thread creation costs on the order of tens of microseconds, so splitting tiny jobs is not worth it.)
Common mistakes
- E0373 borrowed value in spawned closure: add
move; if several threads need the same data, clone it per thread or share it withArc(lesson 3). - Forgetting to join:
mainmay exit before the threads finish, cutting their work off. - Printing from threads and expecting order: collect results and print after joining.
- Spawning one thread per tiny item: thousands of threads cost memory and scheduling time. Split into a few chunks. (The sandbox also limits processes and threads to 256.)
Recap
- Threads share a process's memory; the OS scheduler interleaves them unpredictably.
thread::spawn(closure)returns aJoinHandle<T>;join()waits and returnsResult<T, _>(Err if the thread panicked).- Spawned closures must own their data: use
move. - Output order from threads is not deterministic; compute in threads, print in one place.
- Split work into chunks, one per thread, and combine the results after joining.
Going further: call std::thread::available_parallelism() to see how many cores the sandbox reports, and use it to choose the number of workers. The Rust Book chapter 16.1 covers the same ground.