The Guard Is the Lock: Critical Sections the Compiler Can See
Mutex::lock() refuses to hand you the data. It returns LockResult<MutexGuard<T>>, and the guard is the centerpiece of the design: you never get a free-floating &mut T, you get an owning handle whose lifetime is the critical section.
use std::sync::Mutex;
let counter = Mutex::new(0);
{
let mut guard = counter.lock().unwrap(); // blocks until acquired; Err only when poisoned
*guard += 1; // DerefMut -> &mut i32
println!("inside: {}", *guard); // Deref -> &i32
} // guard dropped -> released; no unlock() exists
The guard implements Deref and DerefMut targeting T, so *guard and method calls read like ordinary access — but every route to the data passes through the guard, which means the borrow checker polices your critical section. Release is Drop, so it runs on early return, on ?, on break, and during panic unwinding. Forgetting to unlock is not a mistake you can make here: there is no unlock function to call.
The guard also makes three classic C++ bugs unrepresentable:
- Use after unlock — the
&mutcame from the guard, so keeping it after the guard was moved away fails to borrow-check. - Reading without locking —
Mutexexposes no API that produces&Toutside a guard. - Releasing early "for speed" — you cannot drop the guard and keep using the value it guarded.
What the types do not catch is ordering and reentrancy. std::sync::Mutex is not reentrant: a second lock() on a mutex your own thread already holds compiles cleanly and hangs forever, because you are now waiting on yourself. Likewise, two threads acquiring two mutexes in opposite orders compile cleanly and deadlock at runtime — lock order is a protocol the compiler cannot see.
Sometimes you want to check without waiting. try_lock adds a second error axis beside poisoning:
use std::sync::TryLockError;
match stats.try_lock() {
Ok(mut g) => { *g += 1; }
Err(TryLockError::WouldBlock) => { /* someone else holds it — skip this tick */ }
Err(TryLockError::Poisoned(p)) => { /* invariant may be broken — covered next */ }
}
WouldBlock means contention, not failure. Polling with try_lock is legitimate for low-priority bookkeeping; replacing a blocking lock() with a spin loop just burns CPU while pretending to be lock-free.
For read-mostly data the library offers RwLock, trading bookkeeping for concurrency between readers:
use std::sync::RwLock;
let config = RwLock::new(Config { workers: 4 });
let snapshot = config.read().unwrap().workers; // many readers hold guards at the same time
config.write().unwrap().workers = 8; // exactly one writer, no readers while held
Mutex<T> |
RwLock<T> |
|
|---|---|---|
| Concurrent readers | none — they queue | many at once |
| Writer | exclusive | exclusive |
| Per-acquisition cost | lower | higher (reader/writer tracking) |
| Typical failure mode | contention convoy | writer starvation under constant reads |
| Reach for it when | sections are short and mixed | readers dominate and sections are long |
The most productive habit is to keep the guard's scope as small as the data change demands. Compute first, lock only to store the result, drop the guard, then block:
// hazard: guard held across a blocking channel send
let mut q = queue.lock().unwrap();
q.push(job);
tx.send(()).unwrap(); // receiver may need `queue` too -> cycle -> deadlock
// safe: narrow scope, then block
{ let mut q = queue.lock().unwrap(); q.push(job); }
tx.send(()).unwrap();
That second form is the whole discipline in two lines: the guard's scope is the critical section, visible in the source, and the blocking call sits outside it. If an interviewer asks what happens when you hold a lock too long, the answer is throughput: four threads doing 5 ms of work under one mutex run at roughly the speed of one thread, because every arrival queues behind the guard you forgot to drop.
Poisoned Locks, Long Holds, and Picking the Right Shape
When a thread panics inside its critical section, unwinding still runs the guard's Drop, so the mutex is released and nobody waits forever. What changes is a flag: std marks the mutex poisoned, because the dying thread may have been halfway through a mutation and the guarded value can now violate its invariant — a ledger with a half-applied transfer, a pool with a checked-out connection that will never come back. Every later lock() returns Err(PoisonError) instead of silently handing back possibly-broken state.
use std::sync::{Mutex, MutexGuard, PoisonError};
fn lock_recovering<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner) // take the guard deliberately
}
PoisonError::into_inner() hands back the guard anyway, so poisoning is advisory, not fatal — std never inspects your data, it only records that a panic escaped from under the lock. You have three real choices, and the right one follows the invariant:
| Policy | Code | Consequence |
|---|---|---|
| Fail fast | m.lock().unwrap() |
one worker's panic becomes a panic in every locker — how a blip becomes an outage |
| Repair | Err(e) => { let mut g = e.into_inner(); repair(&mut g); g } |
you re-establish the invariant before using the data |
| Discard | take the guard, clear and rebuild the value | correct when any committed state is trustworthy but incomplete |
Whichever you pick, pick it deliberately. repair above is your own function — anything that restores the invariant while you hold the guard — and .unwrap() is a decision too, it just delegates the failure policy to whoever panicked first.
The second failure mode is invisible to both the borrow checker and the lock: hold time. If the guarded section takes 5 ms, the threads are serialized whether you planned it or not. Three fixes, in the order you should try them:
- Compute outside the lock. Fetch, parse, and format first; lock only to insert the finished result.
- Shard the hot state so threads touching different keys never meet:
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
type Shards = Arc<Vec<Mutex<HashMap<u64, u64>>>>;
let shards: Shards = Arc::new((0..64).map(|_| Mutex::new(HashMap::new())).collect());
let key = 1234_u64;
{
let mut map = shards[(key % 64) as usize].lock().unwrap(); // other keys use other locks
map.insert(key, 1);
}
- Change the shape. Read-mostly data belongs behind
RwLock(previous chapter), and when the workload really is "hand this value to exactly one consumer", go back to Message Passing with mpsc: a channel transfers ownership instead of guarding it, so the contention disappears rather than shrinking.
One rule for async code, which you will meet in async fn and await: never hold a std mutex guard across an .await. The awaiting thread parks while holding the lock, starving or deadlocking the runtime; async code reaches for tokio::sync::Mutex, whose guard is designed to live across suspension points.
Deadlock deserves its own sentence because it is the failure these types cannot prevent. The guard makes the critical section compiler-visible, but the order in which two threads acquire two mutexes is not in any type — a global order (always lock accounts before locks, by name or address) or a single combined lock is the fix you state in an interview.
Which brings the topic to its summary question: why is Arc<Mutex<T>> the default shared state type? Atomic counting makes shared ownership race-free, the guard makes the critical section a scoped, compiler-checked fact, and poisoning reports broken invariants instead of hiding them. The bounds that let that stack cross thread::spawn at all are Send and Sync Marker Traits.
Quiz
1. Why must Arc's reference count use atomic instructions while Rc's does not?
2. counter.lock().unwrap() gives you a value you dereference with *guard += 1. What does lock() actually return, and why that shape?
3. A thread panics inside its critical section. What does the next lock() call observe?
4. Which of these is accepted by the compiler yet can still hang the program at runtime?
Flashcards
Question
Why does Arc use atomic instructions for its reference count while Rc does not?
Click to reveal answer
Answer
The count is a read-modify-write shared by every handle. Rc does it with plain loads/stores, which races across threads — a lost decrement leaks, a lost increment causes a double-free — so Rc is marked !Send/!Sync. Arc performs the same update with atomic RMW (lock xadd on x86-64), costing tens of nanoseconds only on clone and drop; dereferencing Arc<T> costs the same as dereferencing T, and clone never copies the payload.
Question
What does Mutex::lock() return, and when is the lock released?
Click to reveal answer
Answer
LockResult<MutexGuard<T>> — a guard, not the data. The guard implements Deref/DerefMut to T, so all access flows through it, and Drop releases the lock on every path: normal scope exit, early return, ?, break, and panic unwinding. There is no unlock() to forget, and using the value after the guard moves away fails to borrow-check.
Question
What is mutex poisoning, and how do you recover from it?
Click to reveal answer
Answer
A panic inside the critical section still releases the lock (Drop runs during unwinding) but sets the poisoned flag, because the value may violate its invariant. Later lock() returns Err(PoisonError); PoisonError::into_inner() hands the guard back so you can repair or rebuild before use. Poisoning is advisory — std never inspects your data — so the policy (fail fast, repair, discard) must follow the guarded invariant.
Question
When does Arc<Mutex<T>> satisfy thread::spawn's bounds, and what fails?
Click to reveal answer
Answer
When T: Send. Arc<T> is Send + Sync only if T is both, and Mutex<T> is Send + Sync whenever T: Send — so Arc<Mutex<i64>> crosses spawn. Arc<RefCell<i32>> does not: RefCell is !Sync, so Arc<RefCell<..>> is neither Send nor Sync and the error says the value cannot be shared between threads safely.
Question
Which shared-state failures escape the type system, and what is the fix for each?
Click to reveal answer
Answer
Two. Lock-order inversion: threads acquiring two mutexes in opposite orders compile cleanly and deadlock — enforce a global order, merge into one lock, or shard. Long hold times: a 5 ms critical section serializes every thread through one mutex — compute outside the lock, shard hot state (Vec of Mutex<Shard>), use RwLock for read-mostly data, or move ownership with a channel instead of guarding it.
Revision Notes
Key Takeaways
- 1. Arc is Rc's shape with an atomic count: clone/drop use atomic RMW so shared ownership cannot race, which is why Arc is Send + Sync where Rc is deliberately !Send/!Sync
- 2. Arc shares ownership and Mutex supplies exclusivity — Arc<Mutex<T>> crosses thread::spawn exactly when T: Send, and the bounds are the compiler checking your concurrency design
- 3. lock() returns a MutexGuard, not the data: Deref/DerefMut give access and Drop releases, making the critical section's extent a compiler-checked fact you can read off the guard's scope
- 4. A panicking holder releases the lock but poisons the mutex; later lock() errs and into_inner() recovers — the handling policy should follow the guarded invariant, not a reflex unwrap
- 5. The types cannot catch lock-order inversion or long hold times: fix ordering with a global order or one lock, fix hold time by computing outside, sharding, RwLock, or channels
Interview Tips
- • "Rc vs Arc?" — name the failure mode first: a non-atomic count races, a lost decrement leaks and a lost increment double-frees, so Rc is opted out with negative impls and Arc pays atomic RMW only on clone/drop
- • "What does lock() return?" — a MutexGuard: RAII release plus Deref/DerefMut, then say what that makes impossible (unlock-then-use, unguarded access, leaked locks) and what it does not (lock ordering)
- • "A thread panicked holding the lock — now what?" — guard Drop released it, std set the poisoned flag, next lock() returns Err; show PoisonError::into_inner() and state the policy your invariant demands
- • "How do you avoid deadlock with mutexes?" — give a global acquisition order or one combined lock, add hold-time hygiene (compute outside the lock, shard, RwLock, or channels), and mention that no compiler can check order
Cheat Sheet
Arc and Mutex for Shared State
let hits = Arc::new(Mutex::new(0_u64));
let h = Arc::clone(&hits); // atomic count bump; payload untouched
let mut g = h.lock().unwrap(); // blocks; Err only if poisoned
*g += 1; // DerefMut -> &mut T
drop(g); // Drop releases — no unlock() exists
| Item | Fact |
|---|---|
Arc vs Rc |
atomic RMW on clone/drop -> Send + Sync; plain count would leak or double-free, so Rc is !Send/!Sync (E0277 at spawn) |
lock() |
returns LockResult<MutexGuard<T>>; guard gives Deref/DerefMut; release is Drop on every path including panic |
try_lock() |
TryLockError::WouldBlock (contention) vs Poisoned (invariant may be broken) |
| Poisoning | panic while holding -> flag set, lock still released; PoisonError::into_inner() recovers deliberately |
| Reentrancy | std mutex is not reentrant — same-thread relock compiles and self-deadlocks |
| Not prevented | lock-order inversion (A->B vs B->A) and long hold times — runtime protocol, not types |
Shape selection: immutable shared value -> Arc<T>, no lock · read-mostly -> RwLock<T> · shared mutable -> Arc<Mutex<T>> · single consumer of owned data -> channel (mpsc).
Hygiene: compute outside the lock · never block (channel send/recv, I/O, .await) while holding a std guard · shard hot state into Vec<Mutex<Shard>> · choose poison policy from the invariant, never by reflex .unwrap().