Skip to content
intermediate Phase 12 · Threads, Channels, and Shared State

Arc and Mutex for Shared State

Share mutable data across threads with atomic counts and exclusive locks.

50m
0 problems
Topic Progress 0%

Sharing One Counter Across Four Threads: Why Arc<Mutex<T>> Is the Answer

After Message Passing with mpsc you can ship values between threads, and ownership proves nobody keeps a stale reference. Then the design changes on you: four workers must increment one shared hit counter, every thread reads a cache registry that a maintenance thread rewrites, a connection pool checks a single free-list on every request. Promoting one owner and shipping updates to it means funneling everything through a thread that may itself be busy. Sometimes every thread has to reach the same value directly. Rust's answer is Arc<Mutex<T>>, and "why that exact type" is one of the most common concurrency questions in a Rust interview.

That single line resolves two independent problems. Shared ownership — each thread needs a handle that keeps the value alive, and whoever holds the last handle frees it. Exclusive mutation — at any instant exactly one thread may be inside the data. Arc supplies the first, Mutex the second, and neither can substitute for the other: Arc alone hands out shared &T with no path to mutation, while a bare Mutex has no way to be duplicated into four threads.

So why not clone an Rc? Because Rc's strong count is bumped with a plain read-modify-write. Two threads racing strong += 1 can both read 3 and both write 4, and one increment vanishes. A lost decrement leaks the allocation; a lost increment leaves the count claiming the value is alive after it was freed, so the final drop is a double-free. The standard library keeps Rc away from threads with negative impls — it is !Send and !Sync — and the compiler says so at the spawn site:

error[E0277]: `Rc<RefCell<Vec<u8>>>` cannot be sent between threads safely
   = note: required for `Rc<RefCell<Vec<u8>>>` to implement `Send`
   = note: required for the closure passed to `std::thread::spawn` to implement `Send`
   = help: the trait `Send` is not implemented for `Rc<RefCell<Vec<u8>>>`

Your concurrency design just failed on paper, before a single race could execute.

Arc is Rc's shape in atomic clothing: Arc::new, Arc::clone (bumps the count, never copies T), Deref to T, and drop-to-zero deallocates the value. The count changes are atomic read-modify-write with the appropriate memory ordering — a lock xadd on x86-64 — costing tens of nanoseconds only on clone and drop, never on access: dereferencing Arc<T> costs exactly what dereferencing T costs.

use std::sync::{Arc, Mutex};
use std::thread;

let hits = Arc::new(Mutex::new(0_u64));
let mut handles = Vec::new();
for _ in 0..4 {
    let h = Arc::clone(&hits);                 // atomic count bump; the counter is untouched
    handles.push(thread::spawn(move || {
        for _ in 0..10_000 {
            let mut guard = h.lock().unwrap();  // blocks until this thread owns the data
            *guard += 1;                        // exclusive: one writer at a time
        }                                       // guard drops here, lock released
    }));
}
for t in handles { t.join().unwrap(); }
assert_eq!(*hits.lock().unwrap(), 40_000);

Every one of the four threads ran its 10,000 increments against the same memory and the total is exact, because the mutex serialized every mutation while the arc kept the memory alive for all four owners. Each layer earns its place:

Type What it provides What it costs
Rc<T> shared ownership, single thread nothing — and !Send/!Sync
Arc<T> shared ownership, any thread atomic RMW on clone and drop
Mutex<T> exclusive access to one value lock overhead plus contention
RwLock<T> many readers or one writer heavier bookkeeping, writer starvation

Two limits worth stating before an interviewer states them for you. First, Arc<Mutex<T>> is the answer for shared mutable state; if the value never changes after construction, Arc<Config> with no lock is simpler and faster — every reader of a Mutex queues behind a lock it does not need. Second, the whole stack must satisfy thread::spawn's Send + 'static bounds: Arc<Mutex<i64>> clears them because i64: Send, and the trait arithmetic that decides this is Send and Sync Marker Traits.

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 &mut came from the guard, so keeping it after the guard was moved away fails to borrow-check.
  • Reading without locking — Mutex exposes no API that produces &T outside 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:

  1. Compute outside the lock. Fetch, parse, and format first; lock only to insert the finished result.
  2. 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);
}
  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?

Question 1 options

2. counter.lock().unwrap() gives you a value you dereference with *guard += 1. What does lock() actually return, and why that shape?

Question 2 options

3. A thread panics inside its critical section. What does the next lock() call observe?

Question 3 options

4. Which of these is accepted by the compiler yet can still hang the program at runtime?

Question 4 options

Flashcards

Question

Why does Arc use atomic instructions for its reference count while Rc does not?

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?

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?

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?

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?

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().

Continue Learning

Before You Start

Related Reading