Arc<Mutex<T>> and the three times you do not need it
Arc<Mutex<T>> is what you find when you search for “shared mutable state in Rust”, and it works.
It is also the answer I reach for too early, and three of the four times I have used it, something
simpler was correct.
What each half does
The two wrappers solve two different problems and are frequently confused.
Arc<T> — atomically reference counted. Multiple owners of one heap value, freed when the last
one drops. It gives you sharing, and the contents are still immutable.
Mutex<T> — mutual exclusion. One thread at a time may access the contents. It gives you
mutation, and it does not give you sharing.
You need both to share mutable state across threads, which is why they appear together:
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
for _ in 0..10 {
let counter = Arc::clone(&counter);
thread::spawn(move || {
let mut value = counter.lock().unwrap();
*value += 1;
});
}
Note that Rust’s Mutex owns the data rather than sitting beside it. There is no way to access
the value without locking, which removes the entire class of bug where someone forgets.
Case 1: you only need sharing
If the data is immutable after construction, drop the mutex:
// unnecessary
let config = Arc::new(Mutex::new(Config::load()?));
// correct
let config = Arc::new(Config::load()?);
Reading through a mutex still takes and releases a lock on every access, so this is not just tidier — it removes real contention. A config read on every request through a mutex is a global serialisation point for no reason.
Case 2: reads vastly outnumber writes
RwLock allows many concurrent readers or one writer:
use std::sync::RwLock;
let cache = Arc::new(RwLock::new(HashMap::new()));
// many threads at once
let value = cache.read().unwrap().get(&key).cloned();
// exclusive
cache.write().unwrap().insert(key, value);
The caveat is that RwLock is slower than Mutex for the uncontended case, and writer starvation
is possible under heavy read load. It wins when reads dominate by a wide margin and the critical
section is long enough to matter. For a short critical section under light contention, Mutex is
often faster.
Case 3: it is a counter or a flag
An atomic needs no lock at all:
use std::sync::atomic::{AtomicUsize, Ordering};
let counter = Arc::new(AtomicUsize::new(0));
counter.fetch_add(1, Ordering::Relaxed);
For counters, flags and simple state machines, this is dramatically cheaper — a single CPU instruction rather than a lock acquisition.
Ordering::Relaxed is right for a statistics counter where you only care about the final total.
Anything where the atomic guards other data needs Acquire/Release, and if you are unsure,
SeqCst is the safe default. Getting orderings wrong is subtle in a way locks are not, which is a
real argument for using a mutex when the state is more than a number.
Case 4: message passing instead
Often the shared state exists only because two threads need to coordinate, and a channel expresses that better:
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for item in work {
tx.send(process(item)).unwrap();
}
});
for result in rx {
collect(result);
}
No lock, no Arc, no possibility of deadlock. One thread owns the data at a time and ownership moves
through the channel — which is Rust’s ownership model doing the synchronisation for you.
This is the option that most often turns out to be right, and the one I forget exists.
Warning
In async code, do not hold a std::sync::Mutex across an .await. The lock is held while the
task is suspended, and another task on the same thread that wants it will deadlock. Use
tokio::sync::Mutex — which is async-aware and yields — or restructure so the lock is released
before the await.
When Arc<Mutex<T>> is right
A genuinely shared, genuinely mutable, genuinely complex piece of state: a connection pool, a registry that several tasks add to and remove from, an in-memory store.
Two habits make it safer when you do use it:
Keep the critical section short. Lock, take what you need, unlock. Doing I/O while holding a lock is how you turn a fast service into a slow one.
// bad: the lock is held across the network call
let mut state = shared.lock().unwrap();
state.items = fetch_items().await;
// better
let items = fetch_items().await;
shared.lock().unwrap().items = items;
Lock in a consistent order. Two mutexes acquired in different orders by two threads is the textbook deadlock, and it is the one thing Rust cannot catch for you.
The order I go in now: immutable and Arc → atomic → channel → RwLock → Mutex. By the time I
reach the end of that list, the state is genuinely shared and complex, and the lock is earning its
place.