Mutex

#155 May 2026

155. Mutex<T> — Cross-Thread Exclusive Access, With a Guard Instead of a Panic

Yesterday’s RefCell<T> gives you &mut through &self on a single thread — and panics the moment a second borrow shows up. Mutex<T> is the same idea wearing a hard hat: it’s Sync, so it works across threads, and instead of panicking on contention it just blocks until the other holder is done.

The pain: RefCell is single-threaded, and panics on contention

RefCell<T> is !Sync. The moment you try to share one across threads, the compiler stops you:

1
2
3
4
5
6
7
8
9
use std::cell::RefCell;
use std::sync::Arc;
use std::thread;

let shared = Arc::new(RefCell::new(0u32));
let s2 = Arc::clone(&shared);

thread::spawn(move || { *s2.borrow_mut() += 1; });
// error[E0277]: `RefCell<u32>` cannot be shared between threads safely

Even on one thread, RefCell will panic if a borrow_mut() clashes with a live borrow(). That’s fine for logic bugs you want to find loudly — useless for two real threads that genuinely both want to write.

The fix: lock() returns a guard

Mutex<T> is Sync (when T: Send), and its .lock() method takes &self, blocks until the mutex is free, and hands back a MutexGuard<'_, T>. The guard derefs to &mut T, and releases the lock when it drops:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::sync::Mutex;

let m = Mutex::new(0u32);

{
    let mut g = m.lock().unwrap();   // blocks here if held; we're alone, so it's instant
    *g += 1;
    *g += 1;
}                                     // lock released as `g` drops

assert_eq!(*m.lock().unwrap(), 2);

The .unwrap() is there because lock() returns Result — see “Poisoning” below.

The classic: Arc<Mutex<T>> across threads

Mutex is the inside half. To share ownership across threads you wrap it in Arc — the thread-safe sibling of Rc (covered in Sunday’s morning bite):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0u32));
let mut handles = Vec::new();

for _ in 0..8 {
    let c = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        for _ in 0..100 {
            *c.lock().unwrap() += 1;
        }
    }));
}

for h in handles { h.join().unwrap(); }

assert_eq!(*counter.lock().unwrap(), 800);

Eight threads, a hundred increments each, eight hundred total — no torn writes, no races, because every increment runs while exactly one thread holds the lock.

The footgun: holding the guard too long

The single most common Mutex bug is leaving the guard alive across slow work. Anything between lock() and the guard going out of scope is serialized:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// BAD — lock held for the whole block, including the slow call
let g = state.lock().unwrap();
if let Some(v) = g.cache.get(&key) {
    slow_network_thing(v);            // every other thread is blocked on us
}

// BETTER — get out what you need, drop the guard, then do the slow work
let v = state.lock().unwrap().cache.get(&key).cloned();
if let Some(v) = v {
    slow_network_thing(&v);
}

drop(g) works too if you can’t easily restructure. The mental model: the guard is a lease, keep it as short as you can.

Poisoning, and why .lock() returns Result

If a thread panics while holding the lock, the Mutex is poisoned. Future lock() calls return Err(PoisonError) so you can decide whether the data is still consistent. You can always recover the inner guard with .into_inner():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::sync::{Arc, Mutex};
use std::thread;

let m = Arc::new(Mutex::new(vec![1, 2, 3]));
let m2 = Arc::clone(&m);

let _ = thread::spawn(move || {
    let _g = m2.lock().unwrap();
    panic!("oops");
}).join();                            // panic propagates, then mutex is poisoned

let mut g = match m.lock() {
    Ok(g) => g,
    Err(p) => p.into_inner(),         // we know our data is still fine
};
g.push(4);
assert_eq!(*g, vec![1, 2, 3, 4]);

For data where one panic mid-update really would leave things half-written, the Err is the signal to crash the whole component instead of papering over it.

When to pick Mutex vs RefCell vs RwLock

RefCell<T> for single-threaded interior mutability where contention is a bug. Panics loudly. Zero locking cost.

Mutex<T> when more than one thread needs to write — or might. Blocks instead of panicking. One holder at a time, readers and writers treated identically.

RwLock<T> (covered in this afternoon’s bite) when reads vastly outnumber writes and you want many readers to proceed in parallel. Pricier per-op than Mutex, but the parallelism wins for read-heavy workloads.