Threads
Spawning Threads with std::thread
Section titled “Spawning Threads with std::thread”Rust’s standard library provides OS threads through std::thread::spawn. You pass a closure, and the function returns a JoinHandle<T> — a handle you can use to wait for the thread to finish and retrieve its return value.
use std::thread;
fn main() { let handle = thread::spawn(|| { // This runs on a new OS thread 42 });
let result = handle.join().unwrap(); // block until the thread finishes println!("Thread returned: {}", result);}The closure is move || { ... } when it needs to capture variables from the enclosing scope — otherwise the compiler cannot guarantee the captured data lives long enough.
Moving Data Into a Thread
Section titled “Moving Data Into a Thread”Because a thread may outlive the scope where it was spawned, any data the thread captures must be owned by the thread. The move keyword transfers ownership of captured variables into the closure.
use std::thread;
fn main() { let message = String::from("hello from the main thread");
let handle = thread::spawn(move || { // message is moved here — the thread now owns it println!("{}", message); });
handle.join().unwrap(); // println!("{}", message); // would not compile — moved above}What Happens Without move
Section titled “What Happens Without move”Drop the move keyword and the closure tries to borrow message instead of owning it. The compiler rejects this: it cannot prove the borrow stays valid for as long as the spawned thread might run.
// @expect-error E0373use std::thread;
fn main() { let message = String::from("hello"); let handle = thread::spawn(|| { println!("{}", message); }); handle.join().unwrap();}error[E0373]: closure may outlive the current function, but it borrows `message`, which is owned by the current function --> src/main.rs:5:32 |5 | let handle = thread::spawn(|| { | ^^ may outlive borrowed value `message`6 | println!("{}", message); | ------- `message` is borrowed here |note: function requires argument type to outlive `'static` --> src/main.rs:5:18 |5 | let handle = thread::spawn(|| { | __________________^6 | | println!("{}", message);7 | | }); | |______^help: to force the closure to take ownership of `message` (and any other referenced variables), use the `move` keyword |5 | let handle = thread::spawn(move || { | ++++thread::scope: Borrowing Without Arc
Section titled “thread::scope: Borrowing Without Arc”thread::spawn requires 'static because nothing stops the spawned thread from outliving the function that created it. thread::scope gives the compiler a stronger guarantee instead: every thread spawned inside the scope is joined automatically before scope returns, so borrowing local, non-'static data is sound — no Arc, no cloning, no move of ownership at all.
use std::thread;
fn main() { let numbers = vec![1, 2, 3, 4, 5];
thread::scope(|s| { s.spawn(|| { let sum: i32 = numbers.iter().sum(); println!("sum: {sum}"); }); s.spawn(|| { let max = numbers.iter().max().unwrap(); println!("max: {max}"); }); }); // both threads are joined here, automatically
println!("numbers is still usable: {:?}", numbers);}sum: 15max: 5numbers is still usable: [1, 2, 3, 4, 5]Reach for thread::scope whenever the threads only need to live as long as one function call — it is strictly less ceremony than Arc<Mutex<T>> and gives the same borrow-checker guarantees you already trust for single-threaded code.
Sharing State with Arc<Mutex<T>>
Section titled “Sharing State with Arc<Mutex<T>>”To share mutable data between multiple threads, wrap it in Arc<Mutex<T>>:
Mutex<T>provides mutual exclusion — only one thread can lock it at a time.Arc<T>(Atomically Reference Counted) allows multiple threads to hold a reference-counted pointer to the same allocation.
You clone the Arc before moving it into each thread. The underlying Mutex is shared, not cloned.
use std::thread;use std::sync::{Arc, Mutex};
fn main() { let counter = Arc::new(Mutex::new(0_i32)); let mut handles = Vec::new();
for _ in 0..4 { let counter = Arc::clone(&counter); // cheap ref-count increment let h = thread::spawn(move || { let mut n = counter.lock().unwrap(); // acquire lock *n += 1; }); // lock released when n drops handles.push(h); }
for h in handles { h.join().unwrap(); }
println!("Final count: {}", *counter.lock().unwrap());}Lock Poisoning
Section titled “Lock Poisoning”lock().unwrap() is not just a lazy shortcut — lock() returns a Result, and it is an Err in exactly one situation: another thread panicked while holding the same lock. When that happens, the Mutex is marked poisoned, because the panicking thread might have left the protected data in a half-updated, inconsistent state.
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let data = Arc::new(Mutex::new(vec![1, 2, 3]));
let d = Arc::clone(&data); let handle = thread::spawn(move || { let mut guard = d.lock().unwrap(); guard.push(4); panic!("worker thread panics while holding the lock"); });
let _ = handle.join(); // Err — the panic already printed to stderr
match data.lock() { Ok(_) => println!("lock was not poisoned"), Err(poisoned) => { // The data is still there — into_inner() recovers it despite poisoning. let guard = poisoned.into_inner(); println!("recovered data despite poisoning: {:?}", guard); } }}thread '<unnamed>' (<id>) panicked at src/main.rs:11:9:worker thread panics while holding the locknote: run with `RUST_BACKTRACE=1` environment variable to display a backtracerecovered data despite poisoning: [1, 2, 3, 4]Every subsequent .lock() on this Mutex returns Err forever — poisoning does not clear itself. PoisonError::into_inner() lets you recover the guard anyway (the data is not corrupted here; push completed before the panic!), which is the right call when you know the invariant still holds. Blindly calling .unwrap() on every lock() — as almost every example in this lesson has done so far, for brevity — means a single panicked worker thread turns every future .lock().unwrap() call anywhere in the program into a fresh panic.
if let and Lock Guards (Rust 2024)
Section titled “if let and Lock Guards (Rust 2024)”A lock guard is a temporary value, and the 2024 edition changed when temporaries in an if let condition are dropped. On Rust 2021 the read guard below stays alive through the else branch, so asking for the write lock there deadlocks — the thread waits for a lock it is still holding itself. On Rust 2024 the guard is dropped before else runs:
use std::sync::RwLock;
fn ensure_initialized(value: &RwLock<Option<bool>>) { if let Some(x) = *value.read().unwrap() { println!("already set to {x}"); } else { // Edition 2024 drops the read guard before this branch runs, // so taking the write lock here cannot deadlock. let mut v = value.write().unwrap(); if v.is_none() { *v = Some(true); println!("initialized"); } }}
fn main() { let cell = RwLock::new(None); ensure_initialized(&cell); ensure_initialized(&cell);}Compiling…
It prints initialized, then already set to true. Compiled on edition = "2021" the same program hangs on the first call. If you ever want the old scoping back, write the condition as a match — match still holds its temporaries for the whole expression.
std::sync::atomic: Skipping the Lock Entirely
Section titled “std::sync::atomic: Skipping the Lock Entirely”A Mutex<i32> is overkill for something as simple as a shared counter — every increment pays for a full lock/unlock cycle. std::sync::atomic types perform the increment as one indivisible CPU instruction, with no lock, no Result, and no poisoning to think about:
use std::sync::atomic::{AtomicI32, Ordering};use std::sync::Arc;use std::thread;
fn main() { let counter = Arc::new(AtomicI32::new(0)); let mut handles = Vec::new();
for _ in 0..4 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1000 { counter.fetch_add(1, Ordering::SeqCst); } })); }
for h in handles { h.join().unwrap(); }
println!("Final count: {}", counter.load(Ordering::SeqCst));}Final count: 4000Same result as the Arc<Mutex<i32>> version above, no lock in sight. Ordering::SeqCst (sequentially consistent) is the easiest ordering to reason about and a safe default; weaker orderings (Relaxed, Acquire/Release) exist for when profiling shows the extra synchronization actually costs something. Reach for an atomic when the shared state really is one primitive number or flag — the moment you need to update two related values together, you need a Mutex (or RwLock) to make that update atomic as a whole.
Collecting Results from Multiple Threads
Section titled “Collecting Results from Multiple Threads”A common pattern is to have each thread compute a value and push it into a shared Vec. After all threads complete, you sort the results so the output is deterministic regardless of scheduling order.
use std::thread;use std::sync::{Arc, Mutex};
fn main() { let results = Arc::new(Mutex::new(Vec::new())); let mut handles = Vec::new();
for i in 0..5 { let results = Arc::clone(&results); let handle = thread::spawn(move || { let square = i * i; let mut v = results.lock().unwrap(); v.push(square); }); handles.push(handle); }
for h in handles { h.join().unwrap(); }
let mut final_results = results.lock().unwrap().clone(); final_results.sort(); println!("Squares: {:?}", final_results); let sum: i32 = final_results.iter().sum(); println!("Sum: {}", sum);}Compiling…
Rc<T> Is Not Send
Section titled “Rc<T> Is Not Send”Arc<Mutex<T>> works across threads because Arc’s reference count is atomic. Rc<T>’s count is a plain, non-atomic integer — two threads incrementing it at once would race. The compiler catches this at the type level, before any code runs:
// @expect-error E0277use std::rc::Rc;use std::thread;
fn main() { let data = Rc::new(42); let handle = thread::spawn(move || { println!("{}", data); }); handle.join().unwrap();}error[E0277]: `Rc<i32>` cannot be sent between threads safely --> src/main.rs:6:32 | 6 | let handle = thread::spawn(move || { | ------------- ^------ | | | | __________________|_____________within this `{closure@src/main.rs:6:32: 6:39}` | | | | | required by a bound introduced by this call 7 | | println!("{}", data); 8 | | }); | |_____^ `Rc<i32>` cannot be sent between threads safely | = help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`Swapping Rc::new for Arc::new fixes it — Arc<i32> is Send, so the closure captures a type that is safe to move across the thread boundary. In one sentence: Rc<T> is !Send because its reference count is a plain Cell<usize> increment/decrement with no synchronization — two threads racing on it would corrupt the count itself, so the compiler forbids it outright rather than risk that race.
A Data Race Is a Compile Error, Even with thread::scope
Section titled “A Data Race Is a Compile Error, Even with thread::scope”thread::scope removes the 'static requirement, but it does not remove the borrow-checker’s exclusivity rule: two scoped threads cannot both hold a mutable borrow of the same variable at the same time, for exactly the same reason two &mut references cannot coexist in single-threaded code.
// @expect-error E0499use std::thread;
fn main() { let mut counter = 0;
thread::scope(|s| { s.spawn(|| { counter += 1; // first mutable borrow }); s.spawn(|| { counter += 1; // ERROR: second mutable borrow of counter at the same time }); });
println!("{counter}");}error[E0499]: cannot borrow `counter` as mutable more than once at a time --> src/main.rs:11:17 | 7 | thread::scope(|s| { | - has type `&'1 Scope<'1, '_>` 8 | s.spawn(|| { | - -- first mutable borrow occurs here | _________| | | 9 | | counter += 1; // first mutable borrow | | ------- first borrow occurs due to use of `counter` in closure 10 | | }); | |__________- argument requires that `counter` is borrowed for `'1` 11 | s.spawn(|| { | ^^ second mutable borrow occurs here 12 | counter += 1; // ERROR: second mutable borrow of counter at the same time | ------- second borrow occurs due to use of `counter` in closure |note: requirement that the value outlives `'1` introduced here --> library/std/src/thread/scoped.rs:203:35 |203 | F: FnOnce() -> T + Send + 'scope, | ^^^^^^
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0499`.This is the whole point of “fearless concurrency” in one diagnostic: the exact bug that would be a silent data race (two threads incrementing counter with no synchronization, losing updates) in C or Go never produces a running binary in Rust at all. Fix it the same way you would fix two conflicting &mut borrows in single-threaded code — synchronize with a Mutex/AtomicI32, or restructure so each thread owns a disjoint piece of the data.
A Deadlock Is Not a Compile Error
Section titled “A Deadlock Is Not a Compile Error”The borrow checker only catches conflicts the type system can see — it cannot see when, at runtime, two threads will each be waiting on a lock the other one holds. Locking two mutexes in inconsistent order across threads is the classic deadlock:
sequenceDiagram participant T1 as Thread 1 participant A as lock_a participant B as lock_b participant T2 as Thread 2 T1->>A: lock "A" T2->>B: lock "B" T1->>B: wants "B" — blocked, T2 holds it T2->>A: wants "A" — blocked, T1 holds it Note over T1,T2: Neither thread can proceed — deadlock
// @no-runuse std::sync::{Arc, Mutex};use std::thread;use std::time::Duration;
fn main() { let lock_a = Arc::new(Mutex::new(0)); let lock_b = Arc::new(Mutex::new(0));
let (a1, b1) = (Arc::clone(&lock_a), Arc::clone(&lock_b)); let t1 = thread::spawn(move || { let _g1 = a1.lock().unwrap(); thread::sleep(Duration::from_millis(50)); // give t2 time to take lock_b let _g2 = b1.lock().unwrap(); // waits for t2 to release lock_b — forever println!("t1 acquired both locks"); });
let (a2, b2) = (Arc::clone(&lock_a), Arc::clone(&lock_b)); let t2 = thread::spawn(move || { let _g1 = b2.lock().unwrap(); thread::sleep(Duration::from_millis(50)); // give t1 time to take lock_a let _g2 = a2.lock().unwrap(); // waits for t1 to release lock_a — deadlock println!("t2 acquired both locks"); });
t1.join().unwrap(); t2.join().unwrap(); // This program compiles cleanly and then hangs forever — neither thread // ever prints, and the process never exits.}t1 takes lock_a then wants lock_b; t2 takes lock_b then wants lock_a. Each thread sleeps just long enough to guarantee the other one has already taken its first lock, so both threads end up waiting for a lock the other is holding — forever. The fix is a discipline the compiler cannot enforce for you: always acquire multiple locks in the same global order everywhere in the program (e.g., always lock_a before lock_b). With a consistent order, one thread always wins the race for the first lock and the second thread simply waits its turn instead of deadlocking.
How Many Threads Should I Spawn?
Section titled “How Many Threads Should I Spawn?”std::thread::available_parallelism() reports how many threads can plausibly run at once on the current machine (roughly, the number of CPU cores):
use std::thread::available_parallelism;
fn main() { let n = available_parallelism().map(|p| p.get()).unwrap_or(1); println!("available parallelism is at least 1: {}", n >= 1);}available parallelism is at least 1: trueSpawning far more OS threads than available_parallelism() reports does not add throughput for CPU-bound work — it adds context-switching overhead. Use it (or a thread-pool crate that already does) to size a worker pool instead of guessing a fixed number.
Mental Model
Section titled “Mental Model”Every synchronization primitive in this lesson is really answering the same question the borrow checker already asks in single-threaded code — “who is allowed to touch this data right now?” — just enforced across threads instead of within one function. Arc answers “who owns it” (everyone, via shared, reference-counted ownership). Mutex/RwLock answer “who can touch it right now” (whoever holds the guard — the same exclusivity as &mut T, checked at runtime instead of compile time). Send/Sync answer “is it even legal to hand this to another thread at all” — checked at compile time, before any of the runtime machinery runs.
Trade-offs
Section titled “Trade-offs”thread::scope beats Arc<Mutex<T>> whenever the threads’ lifetime matches one function call — less code, no heap allocation for the Arc, no lock. Arc<Mutex<T>> is what you reach for when threads (or the data itself) need to outlive the function that spawned them. Atomics beat Mutex for a single primitive value — no lock, no poisoning — but stop being an option the moment two related values must change together atomically. RwLock beats Mutex only when reads vastly outnumber writes; under mixed or write-heavy workloads a plain Mutex is simpler and often just as fast, since RwLock’s reader-tracking has its own overhead.
Common Misconceptions
Section titled “Common Misconceptions”- “
Arc<Mutex<T>>is the default way to share state between threads.” It is the default when threads must outlive the spawning function. When they don’t,thread::scopeavoids theArcand the lock entirely. When the shared state is one number or flag, an atomic avoids the lock too. - “A poisoned
Mutexmeans the data inside is corrupted.” Poisoning only means some thread panicked while holding the lock — it says nothing about whether the data itself is actually in a bad state.into_inner()lets you inspect and decide;.unwrap()-ing everylock()treats “maybe corrupted” the same as “definitely corrupted” and panics regardless. - “If it compiles, it can’t deadlock.” The borrow checker prevents data races (unsynchronized concurrent access), not deadlocks (two threads waiting on each other’s locks forever). A deadlocking program is perfectly valid, race-free Rust — it just never makes progress.
Reading AI-generated Code
Section titled “Reading AI-generated Code”AI-generated Rust defaults to Arc<Mutex<i32>> for shared counters even when the update is a single, simple increment — the pattern it has seen most often in tutorials — without considering whether an atomic would do the exact same job with less overhead and no Result/poisoning to handle.
🐛 Spot the bug
use std::sync::{Arc, Mutex};use std::thread;
fn count_events(n: usize) -> i32 { let counter = Arc::new(Mutex::new(0)); let mut handles = Vec::new();
for _ in 0..n { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { let mut guard = counter.lock().unwrap(); *guard += 1; })); }
for h in handles { h.join().unwrap(); }
*counter.lock().unwrap()}Show the answer
This works, but every increment pays for a full lock acquire/release, and .lock().unwrap() will panic the entire program the moment any other user of this Mutex ever panics while holding it. Since the shared state is a single i32 being incremented — not two values that need to change together — AtomicI32 with fetch_add(1, Ordering::SeqCst) does the identical job with no lock, no Result to .unwrap(), and no poisoning to think about at all. Reach for Mutex/RwLock only once you need to update more than one related value as a single atomic step.