#156 May 22, 2026

156. RwLock<T> — Many Readers OR One Writer, When Reads Dominate

This morning’s Mutex<T> treats every caller the same: one at a time, no matter what they’re doing. If ninety-nine of them only want to read, that’s ninety-nine threads serialized behind a lock they didn’t need. RwLock<T> splits the door in two — many readers OR one writer — so read-heavy workloads actually fan out.

The pain: Mutex serializes readers too

A Mutex doesn’t know or care whether you’re going to mutate. Two threads that just want to peek at a config still queue up:

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

let config = Arc::new(Mutex::new(vec!["a", "b", "c"]));
let mut handles = Vec::new();

for _ in 0..8 {
    let c = Arc::clone(&config);
    handles.push(thread::spawn(move || {
        let g = c.lock().unwrap();        // all 8 threads serialize here
        g.iter().map(|s| s.len()).sum::<usize>()
    }));
}

Eight threads, eight reads, zero writes — and they still run one at a time. For a config that’s read on every request and updated once an hour, that’s a lot of wasted parallelism.

The fix: read() and write()

RwLock<T> has two acquire methods, and they map directly onto the two halves of the aliasing rule:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
use std::sync::RwLock;

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

// Many readers can hold a read guard at the same time.
{
    let r1 = lock.read().unwrap();
    let r2 = lock.read().unwrap();
    assert_eq!(r1.len(), 3);
    assert_eq!(r2.len(), 3);
}

// Exactly one writer at a time, with no readers alive.
{
    let mut w = lock.write().unwrap();
    w.push(4);
}

assert_eq!(*lock.read().unwrap(), vec![1, 2, 3, 4]);

read() hands back a RwLockReadGuard that derefs to &T. write() hands back a RwLockWriteGuard that derefs to &mut T. Both release on drop. The whole point: any number of read() guards can be alive at once, as long as no write() guard is.

The classic shape: Arc<RwLock<T>> with many readers

The pattern is the same Arc<_>-wraps-the-shared-thing shape as Arc<Mutex<T>>, but the parallelism story changes. Readers actually run at the same time:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::sync::{Arc, RwLock};
use std::thread;

let cache: Arc<RwLock<Vec<u32>>> = Arc::new(RwLock::new(vec![10, 20, 30]));
let mut handles = Vec::new();

// 8 readers, all running concurrently.
for _ in 0..8 {
    let c = Arc::clone(&cache);
    handles.push(thread::spawn(move || {
        let g = c.read().unwrap();
        g.iter().sum::<u32>()
    }));
}

// One writer, runs alone.
{
    let c = Arc::clone(&cache);
    handles.push(thread::spawn(move || {
        let mut w = c.write().unwrap();
        w.push(40);
        0
    }));
}

let sums: Vec<u32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// Every reader saw either the pre-write or post-write state, never a torn one.
assert!(sums.iter().all(|&s| s == 60 || s == 100 || s == 0));

The point isn’t the assertion — it’s that the eight readers can interleave freely on real hardware. Swap in a Mutex and they’d be a stairstep.

The footgun: writer starvation

A reader-heavy workload can keep the lock in “shared” mode forever. A writer waiting on write() blocks every new reader from joining (on most platforms), but the current readers keep working — and as soon as one of them is done, if a new reader sneaks in before the writer is scheduled, the writer stays parked. The standard library’s RwLock does not promise any particular fairness policy, and historically the behavior varied per OS.

Two practical takeaways:

  1. Keep the write path short. Compute what you want to write outside the lock; take write() only to swap the result in.
  2. If you find yourself reaching for “give readers priority” or “give writers priority” knobs, you’ve outgrown std::sync::RwLock. Either restructure to publish snapshots through Arc::new swaps, or pull in parking_lot::RwLock which exposes fairness controls.

The other footgun: holding the read guard across a write

Same shape as the Mutex “hold the guard too long” bug, but uglier — because nested re-entry on the same thread will deadlock, not panic:

1
2
3
4
5
6
let lock = RwLock::new(0u32);

let r = lock.read().unwrap();
// Some library calls back into us here and tries:
let mut w = lock.write().unwrap();  // deadlock: we still hold `r`
*w += 1;

RefCell would panic loudly with already borrowed. RwLock will silently park the thread, and you’ll see it only in a stack dump. When in doubt, drop the guard explicitly before any call you don’t control.

Going the other way: downgrade to keep reading what you just wrote

Once you’ve finished a write and want to keep reading the same value without a release/reacquire window, RwLockWriteGuard::downgrade is the atomic way across. Worth knowing about for the cache-refresh shape, where the writer thread immediately turns into a long-lived reader.

When to pick RwLock vs Mutex

Mutex<T> for short critical sections, mixed read/write workloads, or anywhere the per-operation cost of a lock matters. Cheaper per acquire, simpler mental model, no fairness surprises.

RwLock<T> when reads vastly outnumber writes and the read critical section is non-trivial — long enough that running them in parallel actually pays for the higher per-acquire cost. Read-only config lookups served on every request, periodically refreshed snapshots, anything shaped like “1000 readers, 1 writer per minute.”

If reads are short (a single field load) and contention is low, plain Mutex is often faster in practice — the extra bookkeeping in RwLock isn’t free. Measure before assuming the read-write split is a win.

← Previous 155. Mutex<T> — Cross-Thread Exclusive Access, With a Guard Instead of a Panic Next → 157. Atomic* — The Thread-Safe Cell for Scalars