Telex logo Telex

Rust Patterns That Matter #19: Arc<Mutex<T>> vs Arc<RwLock<T>>

Post 19 of 22 in Rust Patterns That Matter. Companion series: Building a Chat Server in Rust.

Previous: #18: Typestate | Next: #20: Channels

You have shared state and multiple threads. You reach for Rc<RefCell<T>> because that's what worked in #6. The compiler says no: Rc is not Send, RefCell is not Sync. The thread-safe equivalents are Arc and Mutex. The shapes are identical; the guarantees are stronger.

The motivation

use std::rc::Rc;
use std::cell::RefCell;

let counter = Rc::new(RefCell::new(0));
let c = Rc::clone(&counter);

std::thread::spawn(move || {
    *c.borrow_mut() += 1;
});
error[E0277]: `Rc<RefCell<i32>>` cannot be sent between threads safely
              the trait `Send` is not implemented for `Rc<RefCell<i32>>`

Rc uses a non-atomic reference count. If two threads incremented it simultaneously, the count could corrupt. RefCell uses a non-atomic borrow flag. Same problem. Neither is safe to share across threads.

The parallel to Part I

The mapping is direct:

Arc uses atomic operations for the reference count, making it safe to clone across threads. Mutex uses OS-level locking to ensure only one thread accesses the data at a time.

The pattern: Arc<Mutex<T>>

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

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

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

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

println!("{}", *counter.lock().unwrap()); // 10

Arc::clone gives each thread its own handle to the shared data. .lock() acquires the mutex, returning a guard that dereferences to &mut T. When the guard is dropped, the lock is released. Only one thread holds the lock at a time.

Mutex poisoning

.lock() returns Result<MutexGuard, PoisonError>. If a thread panics while holding the lock, the mutex becomes "poisoned" - the data inside might be in an inconsistent state.

In most programs, .lock().unwrap() is correct. If another thread panicked, your data may be corrupt, and propagating the panic is reasonable. If you want to recover from a poisoned mutex (because you can validate or reset the data), use .lock().unwrap_or_else(|e| e.into_inner()).

Arc<RwLock<T>>

RwLock (read-write lock) allows multiple simultaneous readers or one exclusive writer:

use std::sync::{Arc, RwLock};

let data = Arc::new(RwLock::new(vec![1, 2, 3]));

// Multiple readers — concurrent
let r1 = data.read().unwrap();
let r2 = data.read().unwrap();
println!("{} {}", r1.len(), r2.len()); // both held simultaneously
drop(r1);
drop(r2);

// One writer — exclusive
let mut w = data.write().unwrap();
w.push(4); // no readers or other writers can access while this is held

This mirrors the borrow checker's rules (many &T or one &mut T), but enforced at runtime with locks.

Mutex vs RwLock

RwLock sounds strictly better - it allows concurrent reads. But it has higher per-operation overhead: tracking the reader count requires additional atomic operations. For a lightly contended lock or a lock where reads and writes are roughly balanced, Mutex is faster.

Default to Mutex. Reach for RwLock when:

For most applications, Mutex is the right choice.

When to use it

When not to: if you can avoid sharing state entirely (message passing - #20), do that. Shared mutable state is harder to reason about than message passing, even with Rust's safety guarantees. Arc<Mutex<T>> is safe, but it's still a lock, and locks can contend and deadlock if multiple are held simultaneously.

See it in practice: Building a Chat Server #5: Going Multi-threaded uses this pattern for thread-safe shared server state.