Shared State with Mutex and Arc
When multiple threads need to read and write the same piece of data, Rust’s usual compile-time borrowing rule — one mutable reference or many immutable ones, never both — can’t be checked at compile time anymore, because the compiler has no way to know which thread will run first or when. Rust solves this with two cooperating types: Mutex<T>, which moves the "one writer at a time" rule from compile time to runtime, and Arc<T>, an atomically reference-counted pointer that lets several threads jointly own the same value. Together they let you share mutable state across threads without data races, and the compiler still refuses to compile code that would let a thread sneak around the rules.
Overview: Why You Need Mutex and Arc
Recall the ordinary borrowing rule: at any point, a value can have either one mutable reference or any number of immutable references, and the borrow checker verifies this by statically tracing every borrow’s lifetime through your source code. That works beautifully for single-threaded code, but it breaks down across threads: the compiler cannot predict the interleaving of two threads running at the same time, so it cannot prove at compile time that only one of them is writing at once. If Rust let you freely share a mutable reference between threads, two threads could write to the same memory simultaneously — a data race, which is undefined behavior and one of the most notorious classes of bugs in C and C++.
Mutex<T> (short for "mutual exclusion") solves this by wrapping a value and only ever handing out access through a lock() call. Calling lock() blocks the calling thread until no other thread is holding the lock, then returns a MutexGuard<T> — a smart pointer that derefs to &T or &mut T and, critically, releases the lock automatically when it is dropped (Rust’s RAII pattern, the same mechanism that closes files and frees memory). This is interior mutability: from the outside, a shared &Mutex<T> reference looks read-only, but it lets you obtain exclusive, mutable access underneath, with the exclusivity enforced at runtime instead of compile time.
A Mutex<T> alone doesn’t solve sharing, though — ownership rules still say a value has one owner, and moving a Mutex into one thread would make it unavailable everywhere else. That’s what Arc<T> ("atomically reference-counted") is for: cloning an Arc doesn’t copy the inner data, it increments an atomic counter and hands back a new pointer to the same heap allocation. When the last Arc clone is dropped, the counter hits zero and the data is freed. Because the counter updates are atomic (safe to touch from multiple threads at once), Arc<T> itself is safe to share across threads, unlike its single-threaded cousin Rc<T>, whose reference count is a plain, non-atomic integer. The idiomatic combination is Arc<Mutex<T>>: the Arc lets every thread own a handle to the same data, and the Mutex makes sure only one thread mutates it at a time.
Syntax
There’s no special keyword for this pattern — it’s just ordinary generic types composed together. The general shape looks like this:
Arc::new(Mutex::new(initial_value)) // create a shared, thread-safe, mutable value
let handle = Arc::clone(&shared); // bump the reference count; both point to the same data
let mut guard = shared.lock().unwrap(); // block until the lock is free, then get exclusive access
*guard = new_value; // read or mutate through the guard
// `guard` is dropped here (end of scope) -- the lock is released automatically
Mutex::new(value)— wrapsvalue, producing aMutex<T>with no data race protection needed until it’s shared.Arc::new(x)— heap-allocatesxalongside an atomic reference count, returning anArc<T>.Arc::clone(&shared)— the idiomatic way to get another handle to the same data (prefer this explicit form overshared.clone()so readers immediately see it’s a cheap pointer clone, not a deep copy)..lock()— returnsLockResult<MutexGuard<T>>; theResultisErronly if another thread panicked while holding the lock (a "poisoned" mutex)..unwrap()on the lock result — common in examples and in code where a poisoned lock should simply propagate as a panic; production code sometimes handles theErrcase explicitly instead.
Examples
Example 1: A Mutex on its own
Before adding threads, here’s what Mutex<T> does on a single thread — it lets you mutate a value that’s only reachable through a shared reference.
use std::sync::Mutex;
fn main() {
let counter = Mutex::new(0);
{
let mut num = counter.lock().unwrap();
*num += 1;
}
println!("counter = {}", *counter.lock().unwrap());
}
Output:
counter = 1
The inner block locks the mutex, mutates the integer through the guard, then the guard goes out of scope and drops, releasing the lock. The final line locks it again just to read the value. Locking twice, one after another, is fine — the problem (covered in Common Mistakes below) is locking twice at the same time.
Example 2: Sharing a counter across threads
This is the classic use case: ten threads each incrementing the same counter.
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!("Result: {}", *counter.lock().unwrap());
}
Output:
Result: 10
Each loop iteration clones the Arc (bumping the reference count, not copying the integer) and moves that clone into the spawned thread’s closure — this is why the inner variable is shadowed with the same name counter. Each thread locks the mutex, increments, and the guard drops when the closure ends, releasing the lock for the next thread. handle.join() blocks the main thread until each spawned thread finishes, so by the time we print, all ten increments have happened.
Example 3: Collecting results from multiple threads
A more realistic pattern: several worker threads each push a result into a shared log.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let log = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
for id in 0..3 {
let log = Arc::clone(&log);
let handle = thread::spawn(move || {
let message = format!("worker {} finished", id);
let mut entries = log.lock().unwrap();
entries.push(message);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
let mut entries = log.lock().unwrap();
entries.sort();
println!("log has {} entries", entries.len());
for entry in entries.iter() {
println!("{}", entry);
}
}
Output:
log has 3 entries
worker 0 finished
worker 1 finished
worker 2 finished
Real threads don’t guarantee which one finishes first, so without sorting, the three log lines could print in any order from run to run — sorting the vector before printing makes the output deterministic. Notice that entries.sort() works directly on the MutexGuard: it derefs to &mut Vec<String>, so the guard behaves just like a normal mutable reference to the vector for as long as it’s alive.
How It Works Step by Step
Walking through Example 2: Arc::new(Mutex::new(0)) allocates one i32 on the heap, wrapped in a Mutex‘s internal lock state, wrapped again in an Arc‘s atomic reference count (starting at 1). Each call to Arc::clone atomically increments that count and returns a new Arc value pointing at the same heap allocation — ten clones bring the count to 11 (the original plus ten). Each thread::spawn call hands the closure, and the Arc clone moved into it, to a new OS thread. Because the closure only captures the Arc (not a reference into the stack), and Arc<Mutex<i32>> implements Send, the compiler accepts moving it across the thread boundary. Inside each thread, .lock() asks the OS-level lock: if free, it’s acquired immediately; if held by another thread, this thread blocks until it’s released. Only one thread at a time can be inside the critical section between lock() and the guard’s drop, so the ten increments can never race — they happen one after another, in some unpredictable but always-safe order. join() on each handle blocks the main thread until that worker thread’s closure returns, guaranteeing all ten increments are done before the final lock() and print. When the last Arc clone (inside main) is dropped at the end of the program, the reference count reaches zero and the Mutex<i32> is freed.
Common Mistakes
Mistake 1: Using Rc instead of Arc across threads
Rc<T> looks identical to Arc<T> at a glance, but its reference count is a plain (non-atomic) integer, so two threads bumping it at once could corrupt the count. Rust’s Send trait exists specifically to prevent this: Rc<T> does not implement Send, so trying to move one into a spawned thread is a compile error, not a runtime bug.
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Rc::new(Mutex::new(0));
let counter2 = Rc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter2.lock().unwrap();
*num += 1;
});
handle.join().unwrap();
println!("{}", *counter.lock().unwrap());
}
Output:
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
= help: within the closure, `Rc<Mutex<i32>>` needs to implement `Send`
The fix is simply to use Arc instead of Rc — exactly the swap made in Example 2 above. There’s a small runtime cost (atomic increments are slightly slower than plain ones), which is why Rc still exists as the cheaper single-threaded choice.
Mistake 2: Forgetting mut on the guard binding
A MutexGuard only grants mutable access through its DerefMut implementation if the binding itself is declared mutable — the same rule that applies to any other variable.
use std::sync::Mutex;
fn main() {
let counter = Mutex::new(0);
let num = counter.lock().unwrap();
*num += 1;
println!("{}", *num);
}
Output:
error[E0596]: cannot borrow `num` as mutable, as it is not declared as mutable
The lock is granted at runtime regardless — this is purely the ordinary compile-time mutability check applying to the guard variable. Add mut to fix it:
use std::sync::Mutex;
fn main() {
let counter = Mutex::new(0);
let mut num = counter.lock().unwrap();
*num += 1;
println!("{}", *num);
}
Output:
1
Mistake 3: Deadlocking by locking the same Mutex twice
std::sync::Mutex is not reentrant: a thread that already holds the lock will block forever if it tries to lock the same mutex again before releasing it. This compiles perfectly fine — the borrow checker has no concept of runtime locks — but running it hangs the program forever.
use std::sync::Mutex;
fn main() {
let data = Mutex::new(5);
let first = data.lock().unwrap();
let second = data.lock().unwrap();
println!("{} {}", *first, *second);
}
The second lock() call waits for first to be dropped, but first is still in scope and won’t drop until after that same line finishes — a self-deadlock. The fix is to make sure the first guard is dropped before acquiring the lock again, typically by scoping it in a block:
use std::sync::Mutex;
fn main() {
let data = Mutex::new(5);
{
let first = data.lock().unwrap();
println!("first: {}", *first);
} // `first` is dropped here, releasing the lock
let second = data.lock().unwrap();
println!("second: {}", *second);
}
Output:
first: 5
second: 5
The same category of bug shows up with two different mutexes locked in inconsistent order by two threads — thread A locks mutex 1 then waits for mutex 2, while thread B locks mutex 2 then waits for mutex 1. Neither the compiler nor the runtime can rescue you from that; only disciplined lock ordering can.
Best Practices
- Keep the critical section — the code between acquiring the guard and it being dropped — as short as possible; long-held locks serialize threads that could otherwise run in parallel.
- Use
Arc::clone(&x)rather thanx.clone()for cloning anArc, so it’s visually obvious at every call site that it’s a cheap reference-count bump, not a deep copy. - Prefer scoping a guard in its own block (or calling
drop(guard)explicitly) when you need the lock released before more work happens later in the same function. - Handle the
Resultfrom.lock()deliberately: it’sErronly when another thread panicked while holding the lock (a "poisoned" mutex) — decide whether propagating that panic via.unwrap()is really what you want. - Always acquire multiple locks in the same global order across every thread to avoid deadlocks; if you find yourself nesting locks often, that’s usually a sign the data model needs restructuring.
- Reach for
Mutex/Arcwhen threads genuinely need to share ownership of mutable data; for simple producer/consumer patterns, consider channels (std::sync::mpsc) instead, which avoid shared mutable state entirely.
Practice Exercises
- Take Example 2 and change the loop to spawn 50 threads instead of 10. Predict the final printed value before you check — then explain in one sentence why the order of increments doesn’t matter to the result.
- Write a program that shares an
Arc<Mutex<Vec<i32>>>among 5 threads, where threadidpushesid * 10into the vector. After joining every thread, sort the vector and print it. Expected output:[0, 10, 20, 30, 40]. - Without writing code, explain why
Rc<RefCell<T>>(the standard single-threaded interior-mutability pair) cannot be substituted forArc<Mutex<T>>when sharing data acrossthread::spawncalls. Mention theSendtrait in your answer.
Summary
Mutex<T>provides interior mutability with exclusive access enforced at runtime via.lock(), instead of the compiler’s usual compile-time borrow checking..lock()returns aMutexGuard<T>that derefs to the inner value and automatically releases the lock when it is dropped.Arc<T>is an atomically reference-counted pointer that lets multiple threads jointly own the same heap allocation; unlikeRc<T>, it is safe to share across threads because its counter updates are atomic.- The idiomatic combination for shared mutable state is
Arc<Mutex<T>>:Archandles shared ownership,Mutexhandles safe mutation. Rc<T>does not implementSend, so the compiler rejects moving it into another thread — useArcinstead.- Locking the same mutex twice on one thread before releasing it, or locking multiple mutexes in inconsistent order, causes a deadlock that the compiler cannot catch — only careful design of your locking prevents it.
