Threads & Message Passing
Prerequisites: Smart pointers: Box, Rc & RefCell
A thread runs code at the same time as the rest of your program. Rust uses
real operating-system threads from std::thread. The ownership rules you
already know are what make Rust threads safe: the compiler rejects programs
where two threads could access the same data unsafely ("data races").
Spawning and joining
thread::spawn takes a closure and runs it on a new thread. It returns a
JoinHandle; calling .join() waits for the thread to finish and returns
Ok(value) with whatever the closure returned, or Err if the thread
panicked.
The closure usually needs move, because the new thread might outlive the
function that spawned it, so it must own everything it uses.
When the main thread returns, the whole program exits — even if spawned threads are still running. Join the handles you care about.
Syntax
use std::sync::mpsc;
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(move || data.iter().sum::<i32>()); // `data` moves into the thread
let total = handle.join().unwrap(); // wait and collect the result
let (tx, rx) = mpsc::channel(); // sender, receiver
thread::spawn(move || tx.send("hello").unwrap());
let msg = rx.recv().unwrap(); // blocks until a value arrives
println!("{total} {msg}");
}
Message passing with channels
mpsc stands for "multiple producer, single consumer". A channel has a
Sender (clone it to get more producers) and one Receiver. Sending a value
moves it into the channel, so the sending thread can't touch it afterwards.
Iterating over the receiver (for msg in rx) yields values until every
sender has been dropped.
Scoped threads
thread::scope creates threads that are guaranteed to finish before the scope
ends. Because of that guarantee they may borrow local variables, with no
move and no cloning.
Example
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
// 1. spawn + join: each thread returns a value.
let handles: Vec<_> = (1..=3)
.map(|id| {
thread::spawn(move || {
thread::sleep(Duration::from_millis(10 * (4 - id)));
id * id
})
})
.collect();
// Joining in order gives results in order, whichever thread finished first.
let squares: Vec<u64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
println!("squares: {squares:?}");
// 2. A channel with several producers.
let (tx, rx) = mpsc::channel();
for worker in 0..3 {
let tx = tx.clone();
thread::spawn(move || {
for job in 0..2 {
tx.send(format!("worker {worker} finished job {job}")).unwrap();
}
});
}
drop(tx); // drop the original sender so the loop below can end
let mut messages: Vec<String> = rx.iter().collect();
messages.sort(); // arrival order varies between runs; sort for stable output
for m in &messages {
println!("{m}");
}
// 3. Scoped threads borrow local data directly.
let words = vec!["alpha", "beta", "gamma", "delta"];
let (left, right) = words.split_at(2);
let (a, b) = thread::scope(|s| {
let h1 = s.spawn(|| left.iter().map(|w| w.len()).sum::<usize>());
let h2 = s.spawn(|| right.iter().map(|w| w.len()).sum::<usize>());
(h1.join().unwrap(), h2.join().unwrap())
});
println!("letters: {a} + {b} = {}", a + b);
println!("words still usable: {}", words.len());
}
Expected output
squares: [1, 4, 9]
worker 0 finished job 0
worker 0 finished job 1
worker 1 finished job 0
worker 1 finished job 1
worker 2 finished job 0
worker 2 finished job 1
letters: 9 + 10 = 19
words still usable: 4
The threads really do run in an unpredictable order. The output is stable only
because results are collected through join in a fixed order and the
channel messages are sorted before printing. Printing directly from inside
each thread would interleave differently from run to run.
Common mistakes
-
Forgetting
movewhen the closure uses a local variable:use std::thread; fn main() { let v = vec![1, 2, 3]; let handle = thread::spawn(|| println!("{v:?}")); handle.join().unwrap(); }This fails with
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function. Addmove, or usethread::scopeif you want to borrow. -
Not joining. If
mainreturns first, unfinished threads are stopped mid-work and their output may never appear. -
Keeping the original
Senderalive.for msg in rxonly ends once all senders are dropped; forgettingdrop(tx)makes the program hang. -
Using a value after sending it:
tx.send(s).unwrap(); println!("{s}");fails witherror[E0382]: borrow of moved value: `s`. -
Assuming output order. Code that prints from several threads, or iterates results in arrival order, gives different output on different runs.
Practice
Split the numbers 1 to 100 into four chunks of 25. Sum each chunk on its own
thread with thread::spawn, collect the partial sums in chunk order with
join, and print them and the grand total.
Show solution
use std::thread;
fn main() {
let numbers: Vec<u64> = (1..=100).collect();
let handles: Vec<_> = numbers
.chunks(25)
.map(|chunk| {
let chunk = chunk.to_vec(); // each thread gets its own copy
thread::spawn(move || chunk.iter().sum::<u64>())
})
.collect();
let partials: Vec<u64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
let total: u64 = partials.iter().sum();
println!("partial sums: {partials:?}");
println!("total: {total}");
}
partial sums: [325, 950, 1575, 2200]
total: 5050