Concurrency in Rust
Concurrency lets a program do more than one thing at once — download a file while updating a progress bar, or crunch a big dataset across every CPU core you have. Most languages let you write concurrent code, but they can’t stop you from writing a data race: a bug where two threads read and write the same memory at the same time with no coordination. Rust is different. The same ownership and borrowing rules that prevent memory bugs in single-threaded code are checked by the compiler for multi-threaded code too, which is why this is often called Rust’s "fearless concurrency" — most data races are caught before your program ever runs, not discovered in production at 3am.
Overview: How Concurrency Works in Rust
Under the hood, std::thread::spawn creates a genuine operating-system thread — not a lightweight "green thread" managed by a language runtime. Each thread gets its own stack and is scheduled by the OS, just like a thread you’d create in C or Java. Rust threads are therefore relatively heavyweight (a few megabytes of stack by default), but they map directly onto real CPU cores and need no special async runtime just to exist.
The core problem every concurrent program faces is: what happens when two threads read and write the same piece of memory at once, with no coordination? That’s a data race, and it’s undefined behavior — the result can be corrupted data, a crash, or a bug that only appears once in a million runs. Most languages leave preventing this entirely up to your discipline. Rust instead extends its type system with two marker traits that the compiler checks automatically:
- Send — a type is
Sendif a value of that type can be safely moved to another thread. Almost every type isSend. The notable exception isRc<T>, whose reference count is a plain, non-atomic integer: two threads bumping it simultaneously could corrupt the count, soRc<T>is deliberately notSend. - Sync — a type
TisSyncif it’s safe for multiple threads to hold a shared reference&Tto it at once (formally,TisSyncexactly when&TisSend). Types with interior mutability that isn’t thread-safe, likeRefCell<T>, are notSync.
You never implement these traits by hand for ordinary structs — the compiler derives them automatically based on whether every field is Send/Sync. This is what makes the whole system work: for every type in your program, the compiler already knows whether it’s safe to ship across a thread boundary, and thread::spawn‘s function signature simply requires its closure, and everything that closure owns, to satisfy that bound. Violate it, and your program fails to compile instead of failing unpredictably at runtime.
There’s one more piece: a thread::spawn closure must be 'static, meaning it can’t borrow anything with a shorter lifetime than "the rest of the program." That’s why thread closures almost always use the move keyword — move forces the closure to take ownership of the variables it uses instead of borrowing them, so the compiler never has to worry about the spawned thread outliving data it only points to. (Scoped threads, covered below, relax this rule for data that provably can’t be needed after the thread finishes.)
Once data genuinely needs to be read and written from more than one thread, you reach for two tools together: Arc<T> (atomic reference count) gives multiple threads shared ownership of the same heap allocation, and Mutex<T> (mutual exclusion) ensures only one thread can access the value inside at a time, by routing every access through a lock() call that blocks until the lock is free. Arc alone lets you share data but not mutate it safely; Mutex alone has no way to be owned by more than one thread. Together, Arc<Mutex<T>> is the standard pattern for "many threads, one mutable value."
Syntax
The building blocks of Rust concurrency live in std::thread, std::sync, and std::sync::mpsc:
use std::thread;
// Spawn a new OS thread; the closure must own everything it captures
let handle = thread::spawn(move || {
// ... work done on the new thread ...
});
// Block the current thread until the spawned thread finishes
let result = handle.join().unwrap();
| Item | Purpose |
|---|---|
thread::spawn(closure) |
Starts a new OS thread running closure; returns a JoinHandle<T> where T is the closure’s return type. |
handle.join() |
Blocks the calling thread until the spawned thread finishes; returns Result<T, Box<dyn Any + Send>> (an Err means the thread panicked). |
Arc::new(v) / Arc::clone(&a) |
Wraps a value for atomic, thread-safe shared ownership; clone bumps a reference count instead of copying the data. |
Mutex::new(v) / m.lock() |
Wraps a value so only one thread can access it at a time; lock() blocks and returns a MutexGuard that unlocks automatically when dropped. |
mpsc::channel() |
Creates a multi-producer, single-consumer queue: a Sender (tx) and Receiver (rx) pair for passing owned values between threads. |
thread::scope(closure) |
Runs threads that may borrow local data, guaranteeing they all finish before scope returns. |
Examples
Example 1: spawn a thread, do work on it, and get a value back through join().
use std::thread;
fn main() {
let handle = thread::spawn(|| {
let mut sum = 0;
for i in 1..=5 {
sum += i;
}
sum
});
let result = handle.join().unwrap();
println!("Sum computed in thread: {}", result);
}
Output:
Sum computed in thread: 15
The closure runs on a brand-new OS thread and returns 15 as its final expression. handle.join() blocks main until that thread finishes, and .unwrap() extracts the value from the Ok result (it would panic only if the spawned thread itself panicked).
Example 2: sharing data safely with Arc<Mutex<T>> across many threads.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap());
}
Output:
Final count: 10
Ten threads each clone the Arc (cheap: it just bumps an atomic counter), lock the shared Mutex, increment the number inside, and let the guard unlock automatically when it goes out of scope. Because every handle is joined before the final println!, the result is always exactly 10 — the Mutex serialized the ten increments so none were lost.
Example 3: passing owned values between threads with a channel, instead of sharing memory.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let messages = vec!["hello", "from", "the", "thread"];
for msg in messages {
tx.send(msg.to_string()).unwrap();
}
});
for received in rx {
println!("Got: {}", received);
}
}
Output:
Got: hello
Got: from
Got: the
Got: thread
The spawned thread owns the sending half (tx) after move, and sends four String values in order. The main thread treats rx as an iterator: it yields each received value in the order it was sent, and the loop ends naturally once tx is dropped (when the spawned thread finishes) and no more values are coming.
Scoped Threads
Ordinary threads must be 'static, which is why the earlier examples needed move and, for shared data, Arc. Since Rust 1.63, thread::scope lets threads borrow local variables directly, because the compiler can prove every thread finishes before the scope ends:
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);
});
});
println!("Scope finished, numbers still usable: {:?}", numbers);
}
Output:
Sum: 15
Max: 5
Scope finished, numbers still usable: [1, 2, 3, 4, 5]
Both scoped threads borrow numbers immutably at the same time, which the borrow checker allows. The order of the "Sum" and "Max" lines isn’t guaranteed, since the two threads run concurrently, but the final line is always last: thread::scope blocks until every thread spawned inside it has finished before returning, so numbers is guaranteed to still be valid afterward.
How It Works Step by Step
Tracing through the Arc<Mutex<T>> counter example shows exactly what the compiler and runtime are doing:
Arc::new(Mutex::new(0))allocates aMutex<i32>on the heap and wraps it in anArcwhose atomic reference count starts at 1.- Each loop iteration calls
Arc::clone(&counter), which does not copy theMutex— it atomically increments the reference count and returns a newArcpointing at the same allocation. That clone, and only that clone, is moved into the thread’s closure. counter.lock().unwrap()blocks the calling thread until it can acquire exclusive access, returning aMutexGuard<i32>— a smart pointer that dereferences to the inner value and automatically releases the lock when it’s dropped at the end of the statement.*num += 1mutates the guarded integer. Since only one thread can hold the lock at a time, the ten increments are serialized: no two threads can be inside the critical section simultaneously, so none of the increments is lost to a race.- Calling
handle.join()on every handle blocksmainuntil all ten spawned threads have returned, guaranteeing every increment has already happened before execution reaches the final line. - The last
counter.lock().unwrap()acquires the lock one more time just to read the value, which is now reliably10. - As each
Arcclone is dropped (when its owning thread ends), the reference count decreases; once it reaches zero theMutexand its inneri32are freed. No manual cleanup is needed.
Common Mistakes
Mistake 1: forgetting move and trying to borrow data into a thread.
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("{:?}", data);
});
handle.join().unwrap();
}
This fails to compile with an error like "closure may outlive the current function, but it borrows data, which is owned by the current function." The closure only reads data, so without move it captures a reference. But thread::spawn requires a 'static closure, because nothing stops you from dropping the JoinHandle and letting the thread run long after main‘s local data would have been freed. The fix is to give the closure ownership:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("{:?}", data);
});
handle.join().unwrap();
}
Output:
[1, 2, 3]
Mistake 2: trying to share an Rc<T> across threads instead of an Arc<T>.
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(5);
let handle = thread::spawn(move || {
println!("{}", data);
});
handle.join().unwrap();
}
This fails with an error like "Rc<i32> cannot be sent between threads safely: the trait Send is not implemented for Rc<i32>." Rc‘s reference count is a plain integer with no synchronization, so two threads cloning or dropping it at once could corrupt it — the compiler refuses to let it cross a thread boundary at all. Swap it for the atomic version:
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(5);
let handle = thread::spawn(move || {
println!("{}", data);
});
handle.join().unwrap();
}
Output:
5
A related runtime gotcha worth knowing even though it isn’t a compile error: if a thread panics while holding a Mutex lock, the Mutex becomes "poisoned," and every future .lock() call returns an Err instead of blocking forever. Calling .unwrap() on that result will panic in turn — which is usually the right default, since it surfaces that some invariant was left broken, but production code sometimes recovers with .lock().unwrap_or_else(|poisoned| poisoned.into_inner()) when it’s safe to keep using the possibly-inconsistent data.
Best Practices
- Prefer message passing (channels) over shared mutable state when the design allows it — "share memory by communicating" usually produces simpler, easier-to-reason-about code than shared locks.
- Reach for
Arc<Mutex<T>>only when threads genuinely need to mutate the same data; don’t add aMutexas a reflexive way to silence the borrow checker. - Keep the time spent holding a lock as short as possible — don’t do expensive computation or I/O while a
MutexGuardis alive, since every other thread waiting on that lock is blocked. - Always
.join()handles you care about, or usethread::scopeso the compiler enforces that every thread finishes before you move on. - Prefer
thread::scopeoverArcwhen the spawning function will simply wait for the threads anyway — it avoids reference counting and lets threads borrow local data directly. - Remember that
.join()returns aResult: a panic in a spawned thread does not crash the whole process by default, so decide deliberately whether to propagate, log, or ignore it rather than always calling.unwrap(). - For CPU-bound work, OS threads (what this lesson covers) are the right tool; for programs juggling thousands of mostly-idle I/O operations (network sockets, timers), an async runtime such as
tokiois usually a better fit — that’s a separate topic from raw threads.
Practice Exercises
- Spawn 5 threads, each printing its own index (0 through 4) captured with
move, then join all of them beforemainexits. (Hint: the order the lines print in is not guaranteed — that’s expected.) - Create an
Arc<Mutex<Vec<i32>>>shared across 3 threads, where each thread pushes its own thread number into the vector. Join all threads, then lock once more and print the vector’s contents. - Extend the channel example so two separate sender threads (each holding a
tx.clone()) send messages into the same channel, while a single receiver loop prints everything it gets. Expected output: 8 "Got: …" lines total, interleaved in some order.
Summary
- Rust threads are real OS threads created with
thread::spawn, which returns aJoinHandle<T>you can.join()to wait for the result. - The
SendandSyncmarker traits let the compiler verify at compile time whether a type can safely cross or be shared across a thread boundary — this is the foundation of "fearless concurrency." - Thread closures must be
'static, so they almost always usemoveto take ownership of captured data instead of borrowing it. Arc<T>gives multiple threads shared ownership;Mutex<T>ensures only one thread mutates the value at a time; together,Arc<Mutex<T>>is the standard pattern for shared mutable state.mpscchannels let threads communicate by sending owned values instead of sharing memory directly, which is often simpler and safer.thread::scope(stable since Rust 1.63) lets threads borrow local data directly, since the compiler can guarantee they all finish before the scope returns.- A
Mutexcan become poisoned if a thread panics while holding its lock — know that.lock()returns aResultfor this reason.
