Skip to content

Threads

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.

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
}

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 E0373
use 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::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: 15
max: 5
numbers 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.

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().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 lock
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
recovered 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.

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);
}

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: 4000

Same 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.

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);
}

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 E0277
use 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 E0499
use 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.

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
Two threads locking in opposite order deadlock
// @no-run
use 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.

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: true

Spawning 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.

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.

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.

  • “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::scope avoids the Arc and the lock entirely. When the shared state is one number or flag, an atomic avoids the lock too.
  • “A poisoned Mutex means 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 every lock() 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.

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.

What does JoinHandle::join() do?
Why do thread closures typically use the move keyword?
What is the role of Arc in Arc<Mutex<T>>?
A thread panics while holding a Mutex's lock. What happens to that Mutex afterward?
What does this program do?
use std::thread;
fn main() {
    let mut counter = 0;
    thread::scope(|s| {
        s.spawn(|| counter += 1);
        s.spawn(|| counter += 1);
    });
    println!("{counter}");
}
Why does this deadlock instead of finishing?
// t1: locks a, sleeps, then locks b
// t2: locks b, sleeps, then locks a