Concurrency
Part 2 of 6 · ConcurrencyMutex vs RWLock
A mutex gives one thread exclusive access. An RWLock (shared mutex) lets many readers in at once or one writer. Reach for an RWLock only when reads far outnumber writes and each read section does real work (I/O, parsing, a long scan), so readers actually overlap. For short critical sections a plain mutex is usually faster: every RWLock reader still does an atomic update on the shared reader count, and that cache line ping-pongs between cores. Default to a mutex and measure before switching.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Lesson map
Mutex vs RWLock
Two readers may share the RWLock, and the writer enters only in exclusive mode after the reader count hits zero.
Architecture. Reader 1 Idle. Reader 2 Idle. RWLock Exclusive. Writer Writing
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB reader_1["Reader 1 Idle"] reader_2["Reader 2 Idle"] rwlock["RWLock Exclusive"] writer["Writer Writing"] reader_1 -->|Read lock| rwlock reader_2 -->|Read lock| rwlock writer -->|Write wait| rwlock reader_1 -->|Read unlock| rwlock reader_2 -->|Read unlock| rwlock rwlock -->|Exclusive| writer writer -->|Write unlock| rwlock reader_2 -->|Cut in| rwlock rwlock -->|Starves| writer reader_1 -->|Still held| rwlock reader_1 -->|Serialize| writer reader_2 -->|Ping-pong| rwlock
Overview
Both locks can be correct. The interview question is cost and fairness: which one is faster for this read/write mix and section length, and which one can starve a writer.
In production the wrong pick shows up as a throughput ceiling (a mutex around a read-heavy cache) or as writer starvation and p99 spikes (a reader-preferring RWLock under a steady read stream).
By the end you should be able to:
- Say how a mutex and an RWLock track their state
- Explain reader-count cache-line ping-pong, and why Go's
RWMutexscales poorly with CPU count - Pick reader-preferring, writer-preferring, or fair from the platform you actually run
- Refuse an in-place upgrade, and re-check state after you drop the read lock
- Name what fails if you choose the other lock
Long overlapping reads vs a short critical section
Prefer
RWLock when reads dominate and each read does real work
I/O, parsing, or a long scan lets readers overlap. If a writer must not wait behind that stream, pick a writer-preferring or fair RWLock. Otherwise stay on a mutex until a benchmark says otherwise.
- Shared mode admits any number of readers. A writer waits until the reader count is 0, then holds the lock alone.
- Writer preference blocks new readers once a writer is queued. Writer wait is bounded. Read latency goes up.
- A one-word counter is an atomic, not an RWLock.
Alternative
Mutex on long read-heavy work, or RWLock on tiny sections
A mutex makes readers queue even when none of them changes anything. An RWLock on a few instructions still bounces the reader-count cache line, with more overhead than a mutex, and a reader-preferring policy can starve writers.
- Throughput falls to one reader at a time when the read section is long.
- glibc pthread rwlocks prefer readers by default, so a steady read stream can keep the count above 0.
- Two threads upgrading read to write at once each wait for the other reader to leave.
Happy path: many readers, then one writer
The seven steps match Diagram 1 below. Shared mode lets readers overlap. The writer enters only when the reader count hits 0.
- 1
Reader 1 calls read_lock
No writer holds the lock, so shared mode is granted and the reader count becomes 1. - 2
Reader 2 calls read_lock
Reader 2 is admitted too. Both readers are inside at once and the count is 2. - 3
The writer calls write_lock
The writer waits, because the reader count is not 0. - 4
Reader 1 calls read_unlock
The count drops to 1 and the writer keeps waiting. - 5
Reader 2 calls read_unlock
The count reaches 0. - 6
The writer is granted exclusive mode
No reader and no other writer can enter. The writer is alone, exactly like a mutex. - 7
The writer calls write_unlock
The lock is free for the next readers or the next writer.
Diagrams - step by step
Three small diagrams for Mutex vs RWLock. Step numbers in the labels give the animation order.
Diagram 1 - Happy path: many readers share, one writer gets exclusive access
Sequence
- 1
Reader 1 → RWLock
Step 1 read_lock - shared mode granted
- 2
Reader 2 → RWLock
Step 2 read_lock - both readers inside at once
- 3
Writer → RWLock
Step 3 write_lock - waits for reader count to hit 0
- 4
Reader 1 → RWLock
Step 4 read_unlock
- 5
Reader 2 → RWLock
Step 5 read_unlock - reader count is 0
- 6
RWLock → Writer
Step 6 writer granted exclusive mode
- 7
Writer → RWLock
Step 7 write_unlock
Readers overlap because shared mode lets any number of them in at once. The writer only enters when the reader count drops to zero, and then it is alone, exactly like a mutex. This is the case where an RWLock pays off: reads dominate and readers really do overlap.
Diagram 2 - Failure path: writer starvation on a reader-preferring lock
Sequence
- 1
Reader 1 → RWLock
Step 1 read_lock held
- 2
Writer → RWLock
Step 2 write_lock blocks behind R1
- 3
Reader 2 → RWLock
Step 3 new read_lock admitted anyway
- 4
Reader 1 → RWLock
Step 4 read_unlock - R2 still holds
- 5
Writer
Step 5 overlapping readers keep the count above 0 so the writer starves
- 6
Writer
Fix - fair or writer-preferring lock blocks new readers once a writer waits
If new readers can keep joining while a writer waits, a steady read stream keeps the reader count above zero and the writer may never run. Writer-preferring or fair (FIFO) RWLocks stop admitting new readers once a writer is queued. The cost is that readers now wait behind writers, so read latency goes up.
Diagram 3 - Decision: Mutex or RWLock, and what goes wrong with the other
Decisions
- 1
Step 1 Shared data needs protection
- nextStep 2 Reads far outnumber writes?
- ?
Step 2 Reads far outnumber writes?
- NoPick Mutex
- YesStep 3 Read sections do real work, not a few instructions?
- 3
Pick Mutex
- Wrong pick: Mutex on long read-heavy sectionsReaders serialize and throughput drops
- ?
Step 3 Read sections do real work, not a few instructions?
- NoPick Mutex
- YesStep 4 Writers must never starve?
- ?
Step 4 Writers must never starve?
- YesPick fair or writer-preferring RWLock
- NoPick RWLock
- 6
Pick fair or writer-preferring RWLock
- 7
Pick RWLock
- Wrong pick: RWLock on tiny or write-heavy sectionsReader-count cache-line ping-pong, slower than a Mutex
- 8
Readers serialize and throughput drops
- 9
Reader-count cache-line ping-pong, slower than a Mutex
Default to a plain mutex. An RWLock earns its extra bookkeeping only when reads dominate and each read holds the lock long enough for readers to overlap. On tiny sections every reader still does an atomic update on the shared reader count, so the cache line ping-pongs between cores and a mutex is often faster.
How each lock works
A mutex is one word of state: held or free. Unlock happens-before the next lock on that same mutex. Details live on happens-before.
An RWLock tracks a reader count plus a writer flag, and often a count of queued writers. Readers increment the count on entry and decrement it on exit. A writer waits until the count is 0 and then holds the lock alone, exactly like a mutex.
| Mutex | RWLock / shared mutex | |
|---|---|---|
| Holders | One | Many readers or one writer |
| State | One word, held or free | Reader count, writer flag, often a queued-writer count |
| Best case | Short, mixed, simple | Reads dominate and each read does real work |
| Uncontended cost | One CAS to acquire, one store to release | Extra atomic update on the shared reader count |
Python's threading.Lock is a mutex. threading.RLock is a reentrant mutex, not a readers-writer lock. The stdlib has no RWLock. Java pairs ReentrantLock with ReentrantReadWriteLock. C++ pairs std::mutex with std::shared_mutex. Rust pairs Mutex with RwLock.
Why an RWLock can lose
The reader count is shared, written state. Every read_lock and read_unlock is an atomic read-modify-write on the same cache line. With many cores doing short reads, that line bounces between caches and readers serialize on it anyway, now with more overhead than a mutex.
Go has a long-standing report of this: sync.RWMutex scales poorly with CPU count. A laptop benchmark often misses it. Measure at the core count you run in production, and record hold time, wait time, and CPU time spent on the lock word.
If the shared state is one word (a counter, a flag, a published pointer), use an atomic instead of either lock.
Why a mutex can lose
If read sections are long and reads dominate, a mutex makes readers queue behind each other even though none of them changes anything. Throughput is capped at one reader at a time. That is the case where an RWLock pays off: readers really do overlap.
Preference policy
A reader-preferring RWLock keeps admitting new readers while a writer waits, so a steady read stream can starve the writer. A writer-preferring lock blocks new readers once a writer is queued: writer wait is bounded, and read latency goes up. Fair locks (FIFO or phase-fair) alternate.
Know the platform:
- glibc pthread rwlocks prefer readers by default.
- Java
ReentrantReadWriteLockhas an optional fair mode. Unfair is the starvation default. Pick it explicitly. - Go
RWMutexblocks new readers as soon as a writer callsLock.
Holding either lock across I/O while you also need a second lock is how you join the deadlock page. Copy what you need, drop the lock, then do the I/O.
Upgrades
Upgrading a read lock to a write lock deadlocks when two readers try it at once, because each waits for the other to leave. Most RWLocks do not support upgrade. Release the read lock, take the write lock, then re-check the state, because it may have changed in between. That re-check is the same rule as a Mesa wait: the predicate can be false by the time you run.
Some libraries offer an upgradable-read mode that only one thread may hold at a time (Rust parking_lot, Boost upgrade_mutex). If yours does not, do not invent an upgrade.
Checking state under the read lock, releasing it, then writing under the write lock without re-checking is a TOCTOU race. The TypeScript sample marks the same bug inside an async lock: the check and the take must be one synchronous step. An await between them lets another task pass the same check.
What goes wrong with the wrong pick
Mutex on long read-heavy sections: readers serialize and throughput drops to one reader at a time.
RWLock on tiny or write-heavy sections: the extra bookkeeping and the reader-count cache-line ping-pong make it slower than a mutex, and a reader-preferring lock can also starve writers.
A config map that is read on every request and rebuilt once a minute often wants neither lock. Build the new map off to the side and publish it with an atomic pointer swap. Readers take a snapshot with one atomic load. If you must lock, a writer-preferring RWLock fits, because writes are rare and short and must not starve.
Pros and cons
| Approach | Pros | Cons |
|---|---|---|
| Mutex | Simplest. One word of state. Cheapest uncontended path. No preference policy to choose. | Readers serialize. Long read-heavy sections cap throughput at one reader. |
| RWLock (reader-preferring) | Readers overlap. Best read throughput when reads dominate and sections are long. | Reader-count cache-line ping-pong on short sections. Writers can starve. Upgrade deadlocks. |
| RWLock (writer-preferring or fair) | Bounded writer wait. Readers still overlap between writes. | Readers wait behind queued writers, so read latency rises. More complex state. |
Working Python
Run python3 mutex_rwlock.py (stdlib only, about 2 seconds). Reads sleep inside the lock to stand in for real work. With the GIL, CPU-bound Python readers would not run in parallel anyway, so the win shown here is overlap. The in-page sandbox has no operating-system threads, so run this file locally.
"""Mutex vs RWLock: readers overlap, writers are exclusive, and preference policy decides starvation.
Run: python3 mutex_rwlock.py (stdlib only, about 2 seconds)
Reads sleep inside the lock to stand in for real work (I/O, parsing). With the GIL,
CPU-bound Python readers would not run in parallel anyway, so the win shown here is overlap.
"""
import threading
import time
class RWLock:
"""Many readers OR one writer. prefer_writer=True blocks new readers once a writer waits."""
def __init__(self, prefer_writer: bool = False) -> None:
self._cv = threading.Condition() # one mutex + condition guards all counters
self._readers = 0 # readers currently inside
self._writer = False # a writer is inside
self._writers_waiting = 0 # queued writers (used only when prefer_writer)
self._prefer_writer = prefer_writer
def acquire_read(self) -> None:
with self._cv:
# Mesa semantics: always re-check the predicate in a while loop.
while self._writer or (self._prefer_writer and self._writers_waiting > 0):
self._cv.wait()
self._readers += 1 # shared counter every reader must update
def release_read(self) -> None:
with self._cv:
self._readers -= 1
if self._readers == 0:
self._cv.notify_all() # last reader out lets a writer in
def acquire_write(self) -> None:
with self._cv:
self._writers_waiting += 1
while self._writer or self._readers > 0:
self._cv.wait()
self._writers_waiting -= 1
self._writer = True
def release_write(self) -> None:
with self._cv:
self._writer = False
self._cv.notify_all()
def overlap_demo() -> None:
"""Same 8 reads of 50 ms: a Mutex serializes them, an RWLock lets them overlap."""
def run(enter, leave) -> float:
def reader() -> None:
enter(); time.sleep(0.05); leave()
ts = [threading.Thread(target=reader) for _ in range(8)]
t0 = time.perf_counter()
for t in ts: t.start()
for t in ts: t.join()
return time.perf_counter() - t0
m = threading.Lock()
rw = RWLock()
print(f"8 readers with Mutex : {run(m.acquire, m.release):.2f}s (serialized)")
print(f"8 readers with RWLock: {run(rw.acquire_read, rw.release_read):.2f}s (overlapped)")
def starvation_demo(prefer_writer: bool) -> float:
"""4 readers re-enter back to back for 1 s; how long does one writer wait?"""
lock = RWLock(prefer_writer)
stop = time.perf_counter() + 1.0
def reader(offset: float) -> None:
time.sleep(offset) # stagger so read sections always overlap
while time.perf_counter() < stop:
lock.acquire_read(); time.sleep(0.02); lock.release_read()
rs = [threading.Thread(target=reader, args=(i * 0.005,)) for i in range(4)]
for t in rs: t.start()
time.sleep(0.1) # readers are now streaming
t0 = time.perf_counter()
lock.acquire_write(); waited = time.perf_counter() - t0; lock.release_write()
for t in rs: t.join()
return waited
if __name__ == "__main__":
overlap_demo()
print(f"writer wait, reader-preferring: {starvation_demo(False):.2f}s (starved until readers stop)")
print(f"writer wait, writer-preferring: {starvation_demo(True):.2f}s (new readers held back)")Sample run: 8 readers with a mutex took 0.43s, and the same 8 readers with an RWLock took 0.05s. Writer wait under a steady read stream was 0.91s with a reader-preferring lock (starved until the readers stopped) and 0.02s with writer preference.
Working TypeScript
Run npx tsx mutex_rwlock.ts (no dependencies, about 2 seconds). Node runs JavaScript on one thread, but an await inside a critical section lets other tasks interleave, so a read-mostly cache still needs reader/writer locking. Run this file locally. The sleep loops are longer than the in-page sandbox allows.
// Mutex vs RWLock in Node: an async RWLock guarding state across await points.
// Run: npx tsx mutex_rwlock.ts (no dependencies, about 2 seconds)
// Node runs JS on one thread, but an await inside a critical section lets other
// tasks interleave, so read-mostly caches still need reader/writer locking.
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
class AsyncRWLock {
private readers = 0; // readers currently inside
private writer = false; // a writer is inside
private writersWaiting = 0; // queued writers
private waiters: Array<() => void> = [];
constructor(private preferWriter = false) {}
private wake(): void { // Mesa style: wake everyone, each re-checks its predicate
const w = this.waiters; this.waiters = []; w.forEach((f) => f());
}
// Check and take must happen in the same synchronous step: an await between
// them would let another task pass the same check (a TOCTOU bug).
private async acquire(ok: () => boolean, take: () => void): Promise<void> {
while (!ok()) await new Promise<void>((r) => this.waiters.push(r));
take();
}
readLock(): Promise<void> {
return this.acquire(
() => !this.writer && !(this.preferWriter && this.writersWaiting > 0),
() => { this.readers++; });
}
readUnlock(): void { if (--this.readers === 0) this.wake(); }
writeLock(): Promise<void> {
this.writersWaiting++;
return this.acquire(
() => !this.writer && this.readers === 0,
() => { this.writersWaiting--; this.writer = true; });
}
writeUnlock(): void { this.writer = false; this.wake(); }
}
async function overlap(): Promise<void> {
const rw = new AsyncRWLock();
const asMutex = new AsyncRWLock(); // write mode only = a plain mutex
const t0 = Date.now();
await Promise.all(Array.from({ length: 8 }, async () => {
await asMutex.writeLock(); await sleep(50); asMutex.writeUnlock();
}));
const t1 = Date.now();
await Promise.all(Array.from({ length: 8 }, async () => {
await rw.readLock(); await sleep(50); rw.readUnlock();
}));
console.log(`8 reads with Mutex : ${t1 - t0} ms (serialized)`);
console.log(`8 reads with RWLock: ${Date.now() - t1} ms (overlapped)`);
}
async function writerWait(preferWriter: boolean): Promise<number> {
const lock = new AsyncRWLock(preferWriter);
const stop = Date.now() + 1000;
const reader = async (offset: number) => {
await sleep(offset); // stagger so read sections overlap
while (Date.now() < stop) { await lock.readLock(); await sleep(20); lock.readUnlock(); }
};
const readers = [0, 5, 10, 15].map(reader);
await sleep(100);
const t0 = Date.now();
await lock.writeLock(); const waited = Date.now() - t0; lock.writeUnlock();
await Promise.all(readers);
return waited;
}
async function main(): Promise<void> {
await overlap();
console.log(`writer wait, reader-preferring: ${await writerWait(false)} ms (starved)`);
console.log(`writer wait, writer-preferring: ${await writerWait(true)} ms (new readers held back)`);
}
main();Sample run: 8 reads with a mutex took 403 ms, and the RWLock took 53 ms. Writer wait was 919 ms reader-preferring and 17 ms writer-preferring. The comment in acquire marks a real bug found while writing it: checking and taking the lock across an await let every task pass the same check.
Interview Q&A
When does an RWLock beat a mutex?
Answer
When reads far outnumber writes and each read section is long enough for readers to overlap (I/O, parsing, a long scan). Then N readers run concurrently instead of one at a time. If sections are a few instructions, the shared reader-count update dominates and the mutex wins.
Why can an RWLock be slower than a mutex even with all reads?
Answer
Every read_lock and read_unlock is an atomic read-modify-write on the same reader-count word. On many cores that cache line bounces between caches, so readers serialize on the counter. Options: a plain mutex for short sections, sharded or per-CPU reader counts, RCU, or an immutable snapshot published through an atomic pointer. Go issue 17973 is this bug on sync.RWMutex.
What is writer starvation and how do you prevent it?
Answer
With a reader-preferring lock, new readers keep entering while a writer waits, so the count never reaches 0. Prevent it with writer preference (block new readers once a writer is queued) or a fair FIFO or phase-fair lock. The cost is higher read latency. glibc pthread rwlocks prefer readers by default. Go RWMutex blocks new readers as soon as a writer calls Lock. Java ReentrantReadWriteLock has an optional fair mode.
Can you upgrade a read lock to a write lock?
Answer
Not safely in general. Two readers upgrading at once deadlock, each waiting for the other to release. Release the read lock, take the write lock, and re-validate the state. Some libraries offer an upgradable-read mode that only one thread may hold at a time (Rust parking_lot, Boost upgrade_mutex).
A config map is read on every request and reloaded once a minute. What do you use?
Answer
Often neither lock. Build the new map off to the side and publish it with an atomic pointer swap (copy-on-write, RCU style). Readers take a snapshot with one atomic load. If you must lock, a writer-preferring RWLock fits, because writes are rare and short and must not starve.
Is it safe to read a field without the lock if all writers take the mutex?
Answer
No. An unsynchronized read racing with a write is a data race: undefined behavior in C++, and stale or torn values elsewhere. Readers need the read lock, an atomic, or an immutable snapshot. Two concurrent readers do not synchronize with each other, so they must not write.
How would you decide with data?
Answer
Benchmark both locks at the core count you run in production, with your real read/write mix. Record hold time, wait time, and CPU time spent on the lock word. RWLock scaling problems often appear only with many cores, so a laptop benchmark can mislead.
Pitfalls
For a config blob reloaded once a minute, a requests_total counter, and a short mixed map update, choose mutex, RWLock, or atomic, and say why. Then walk two threads that both try to upgrade. If either still held the read lock, release it, take the write lock, and re-check the state.
Go Deeper
- Go
sync.RWMutex - Go issue 17973:
sync.RWMutexscales poorly with CPU count - Rust
std::sync::RwLock - Java
ReentrantReadWriteLock(fair vs non-fair) - POSIX
pthread_rwlock_rdlock - Readers-writer lock (Wikipedia)
Cluster: hub · next Mesa vs Hoare · when locks win