Threads and shared state
Running work at the same time, sending values between threads, and sharing one value safely with Arc and Mutex — the chapter where the ownership rules pay off.
thread::spawn and join
std::thread::spawn starts a closure on a new operating-system thread and hands back a handle. handle.join() waits for it and gives you its return value — so a thread is a function call you can collect later.
A spawned thread may outlive the function that started it, so it cannot borrow local values: the closure must own what it uses. move says so — thread::spawn(move || { … }) — and takes the captured values into the thread. This is the same ownership rule as any other call, and it is why Rust threads cannot dangle references into a frame that has returned.
Sending values instead of sharing them
The simplest safe design is not to share at all: std::sync::mpsc::channel() makes a pipe — the sender goes to the worker, the receiver stays with you, and for message in receiver { … } reads what the workers produced. Each value has one owner at a time; it simply changes hands.
Message passing removes the question of who is allowed to write what. When the shape of the work allows it, it is the design with the fewest sharp edges.
A channel can also be bounded: sync_channel(10) makes send block once ten messages are waiting, which is how a fast producer is held back to the pace of a slow consumer. Unbounded is simpler; bounded is what keeps memory flat when the two sides run at different speeds.
When it must be shared: Arc and Mutex
Sometimes every thread needs the same value. Arc<T> is a reference count that is safe to share — clone it, and the value lives until the last clone is gone. Mutex<T> allows one thread in at a time. Together: Arc<Mutex<Vec<i32>>> is one list, shared, with the door controlled.
mutex.lock() hands you the &mut inside, and the lock is released automatically when that reference goes out of scope. The compiler will not let you touch the data without holding the lock — which is the same rule as borrowing, enforced across threads.
Three threads, two designs
use std::sync::{Arc, Mutex};
use std::sync::mpsc;
use std::thread;
fn main() {
// One owner at a time: values change hands as messages.
let (sender, receiver) = mpsc::channel();
for n in 1..=3 {
let sender = sender.clone();
thread::spawn(move || {
let _ = sender.send(n * n);
});
}
drop(sender);
let mut squares = Vec::new();
for value in receiver {
squares.push(value);
}
squares.sort();
println!("squares from the channel: {:?}", squares);
// Shared, with the door controlled.
let counter = Arc::new(Mutex::new(0usize));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut guard = counter.lock().unwrap();
*guard += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("counted to {}", counter.lock().unwrap());
}Work in parallel, count without losing one
Run it a few times: the channel numbers arrive in different orders, and the count always reaches four. Then spawn eight threads instead of four, and send both the square and the number through the channel as a tuple.
The lock must be held for as little as possible. A thread that holds one while waiting for something else stops every other thread at that line — that is a deadlock, and the compiler cannot see it. Keep the guard in the smallest scope that needs it, and the problem stays away.
What Send and Sync mean
A type is Send when it can be moved to another thread, and Sync when a &T can be shared between threads. Almost everything is both, and the exceptions are the point: Rc<T> is neither, because its count is not atomic; Mutex<T> is Sync when T is Send, because the lock supplies the discipline.
You rarely write these bounds yourself. They sit on thread::spawn and on Arc, and the compiler applies them at the call — a captured Rc is an error before any thread runs. They are marker traits: no methods, just a promise about how the type may be used.
Channels close when the senders go
mpsc means multiple producer, single consumer: clone the sender for each worker, keep one receiver. send returns a Result, because the receiver may already be gone — a worker whose result nobody wants should not panic. When every sender has been dropped, the receiver's iterator ends, and that is the signal the work is finished.
Dropping the original sender matters. If main keeps a clone alive, for value in receiver waits forever after the workers finish, because the channel never closes. drop(sender) is the line that says no more messages are coming.
A worker panic is a value too
join() returns a Result, not a bare value: Ok when the thread finished, Err when it panicked. A panic in a worker does not take the program down — it unwinds that one thread and hands the payload to whoever joins. That is why the examples call .unwrap() on a join: for workers that should never panic, the unwrap is the assertion.
In a program that must keep running, the Err is the thing to log, and the other threads carry on. The same is true of a poisoned mutex: the panic that poisoned it is recoverable information, not the end of the process.
Three threading mistakes. Capturing an Rc in a move closure will not compile, because Rc is not Send — Arc is the shared pointer for threads. Holding a mutex guard while joining a thread that needs the same lock deadlocks: the guard is still held, so the join waits forever. And unwrap() on lock() assumes the lock is never poisoned, which holds only if no thread panics while holding it.
Try these next
- Spawn four threads that each return a number, join them in order, and print the values without sorting them.
- Make a channel worker send a
(name, score)tuple, then collect every message into aHashMap. - Replace an
Arc<Mutex<u32>>counter with a channel that sends increments, and compare which design reads better.
Collect results, then let the channel close
Run it a few times: the handles keep their order, while the channel only promises that every value arrives. Then spawn six workers on each side and make the channel send the square of the number as well.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
Why do spawned threads usually need move closures?
