Shared State: Mutex, Arc, and Send/Sync

Shared State: Mutex, Arc, and Send/Sync

The smart-pointers lesson promised it and the fearless-concurrency lesson named it: this lesson finally builds Arc<Mutex<T>>, the thread-safe replacement for Rc<RefCell<T>>. Starts by trying to send an Rc across a thread anyway and reading the real compiler error - "Rc<i32> cannot be sent between threads safely... the trait Send is not implemented" - which reuses thread::spawn's exact F: Send + 'static bound from the previous lesson's error. From there: Arc as Rc with an atomic counter, Mutex as RefCell's thread-safe cousin (.lock() instead of .borrow_mut(), returning a Result instead of panicking), a ten-thread counter that always prints Result: 10, and what that Result is actually for - a real lock-poisoning panic, captured live. Closes the Smart Pointers and Concurrency section by introducing Send and Sync by name.

18 min read4 learning objectives

What You'll Learn

  • Explain why Rc<T> cannot be sent between threads (the Send trait), and connect the resulting error to thread::spawn's F: Send + 'static bound from the previous lesson
  • Use Arc<T> as a drop-in thread-safe replacement for Rc<T>, with the same new/clone/strong_count API
  • Use Mutex<T> for shared mutable state across threads, and explain why .lock() returns a Result instead of panicking the way RefCell does
  • Combine Arc and Mutex into Arc<Mutex<T>> to safely share and mutate state across multiple threads, and recognize Send and Sync as the two traits the compiler uses to enforce it

Two lessons ago, Rc<RefCell<T>> solved "multiple owners, one of them needs to mutate" — for one thread. The last lesson ended with a promise: swap in Arc for Rc and Mutex for RefCell, and the same shape works across threads. Time to see why the swap is necessary — and exactly what it buys.

Rc doesn't cross threads

move fixed last lesson's borrowing error. Surely it fixes this too:

move is right there. data is genuinely owned by the closure — and it still doesn't compile. Rc<i32> "cannot be sent between threads safely... the trait Send is not implemented for Rc<i32>." Send is new — it's a trait (the traits lesson), implemented automatically by the compiler for almost every type, meaning "safe to move a value of this type to another thread." Rc<T> is one of the rare exceptions, on purpose: Rc::clone bumps a plain integer counter — fast, but not atomic. If two threads bumped it at the same instant, both could read the same number and write back the same incremented value — the count would end up wrong, and a wrong count means Rc<T> could free its data while a clone still points at it. Rust doesn't wait to find out: Rc<T> simply isn't Send, and thread::spawn's bound — F: Send + 'static, the exact line from last lesson's error, just the other half of it on display this time — rejects the closure before any of that can happen.

Arc: Rc's thread-safe cousin

Same names, almost: Arc::new, Arc::clone, Arc::strong_count. Arc stands for "Atomically Reference Counted" — the same idea as Rc's counter, but using an atomic integer: a CPU-level operation that's correct even when two threads increment it at the same instant. Run this and the count goes 1, then 2 after one clone — exactly what Rc would print. But this code compiles and runs, because tasks2 Arc<Vec<String>>is Send. It moves into the spawned thread, gets printed there, and tasks — the original — is still right where it was, printed again in main after .join(). Two owners, two threads, one allocation. That's the half of last lesson's promise that really is just "Rc becomes Arc, nothing else changes."

Mutex: RefCell's thread-safe cousin

Arc<T> solves sharing. It doesn't solve mutating — like &T, an Arc<T> only hands out shared, read-only access. Two threads holding Arc::clones of the same Vec can't both call .push() on it, any more than two &Vec<String>s could. The smart-pointers lesson's RefCell<T> solved exactly this for one thread: interior mutability, checked at runtime. Mutex<T> ("mutual exclusion") is RefCell's thread-safe cousin, and the API rhymes on purpose — m.lock() instead of .borrow_mut(), returning a smart pointer (MutexGuard<T>, the same family as RefCell's RefMut<T>) that derefs with * and unlocks automatically when it's dropped — here, at the inner block's closing }.

But RefCell and Mutex handle "the rule got broken" differently. RefCell panics — one thread, breaking its own rule, is a bug, full stop. Mutex<T> can't take that approach, because two threads both calling .lock() at once is normal — it's the entire point. Instead, the second caller just blocks: waits, until the first MutexGuard is dropped. And .lock() returns a Result, not the guard directly — Ok(MutexGuard<i32>) normally, .unwrap() the familiar move from the option-and-result lesson — hiding a real case, coming up in two blocks. Run this one and the Debug output shows the value updated right where it was left: m = Mutex { data: 6, poisoned: false, .. }.

Putting them together: Arc<Mutex<T>>

Ten threads, one i32, shared and mutated by all of them — the exact shape Rc<RefCell<T>> couldn't reach. Arc::new(Mutex::new(0)) is Arc<Mutex<i32>>, last lesson's promised type, finally assembled. Inside the loop, let counter = Arc::clone(&counter) shadows (the variables lesson) the outer counter with a fresh clone, owned by this iteration alone and ready to move into its own thread. Each thread's closure: .lock().unwrap() for a MutexGuard<i32>, *num += 1 through it, then the guard drops at the closure's closing }, unlocking for whoever's next. handles — a Vec of JoinHandles, last lesson's exercise pattern — collects all ten, then a second loop .join()s every one before the final print. Run it ten times, a hundred times: the answer is always Result: 10. Every increment counted, none lost, none doubled — and not one line of unsafe. That's fearless concurrency: the type system makes the unsafe version — a bare shared i32, mutated from ten threads with no lock — impossible to even write.

Why .lock() returns a Result: poisoning

One thread, holding the lock (_num, a live MutexGuard), panics — the understanding-errors lesson's panic!, on purpose, mid-lock. handle.join() returns Err in this case — the fearless-concurrency lesson mentioned that .join() can fail and moved on; here it actually does — and let _ = ... discards it without unwrapping, so main doesn't panic too. But the MutexGuard that thread was holding never unlocked cleanly — its Drop ran during the panic, and Mutex<T> marks itself poisoned when that happens: a signal that the data inside might be left half-updated. The next .lock(), back in main, reflects that — not Ok(MutexGuard<i32>) but Err(PoisonError { .. }). This is the case .unwrap() was always going to panic on, made concrete — and it's why .lock() returns a Result at all: Result from the option-and-result lesson, doing its actual job — forcing a decision about a real failure mode, instead of a guarantee that can't quite be kept.

Send and Sync, named

One more pair, both promised last lesson, neither shown until now: Send and Sync. Send you've now seen directly — a type is Send if it's safe to move a value of that type to another thread; thread::spawn's F: Send + 'static bound is the compiler checking exactly that, for the closure and everything it captured. Sync is Send's sibling, for sharing instead of moving: a type is Sync if &T — a shared reference to it — is itself Send, i.e. safe to hand to another thread while the original stays put. Almost every type is both, automatically, with no work from you. Rc<T> and RefCell<T> — this whole section's starting point — are the exceptions: neither is Sync, and Rc<T> additionally isn't Send either — two separate reasons Rc<RefCell<T>> refuses to compile across threads. Arc<T>'s atomic counter and Mutex<T>'s lock are exactly the machinery that make both promises true again — which is also the answer to why they're separate types instead of the compiler just "allowing" Rc/RefCell across threads: being Send or Sync isn't a label you can attach — it has to be backed by a real mechanism, and Arc/Mutex are that mechanism.

Quick exercise

  1. Change the loop in the Arc<Mutex<i32>> counter from 0..10 to 0..1000. Run it a few times — still always Result: 1000? Mutex scales; the only cost is threads occasionally waiting their turn for .lock().
  2. In the Mutex::new(5) example, try locking it twice in the same scope — the way the smart-pointers lesson's exercise double-.borrow_mut()'d a RefCell: let a = m.lock().unwrap(); let b = m.lock().unwrap();. RefCell panicked here. Mutex does something else: it blocksmain just hangs, forever, waiting for a lock it already holds to free up. (This is a real bug, called a deadlock — if you try it, you'll need to stop the program by hand.) RefCell's rule is enforced by panicking; Mutex's is enforced by waiting — harmless between two different threads, fatal within one.

Three lessons, one throughline. The smart-pointers lesson finally explained Box<dyn Error>, a type that had been sitting unexplained since lesson 21, then built Rc<RefCell<T>> — multiple owners, shared mutability, one thread. The fearless-concurrency lesson made "one thread" literal: real OS threads, move closures, and channels for handing values between them, ownership intact the whole way. This lesson closed the loop Rc<RefCell<T>> opened — Arc<Mutex<T>>, the same shape, safe across as many threads as the hardware has cores, with Send and Sync checked by the compiler before the program ever runs.

That's "Smart Pointers & Concurrency" — the hardest ideas in this course, and the ones that make Rust Rust. The next lesson is the last stretch: a taste of async/await, Rust's other concurrency model — for waiting on many things at once instead of running many things at once — followed by a short look at writing your own macro_rules! macros, and a map of where to go from here.