The idea
What it is
A normal lock — a mutex — is a room with one key. Whoever holds the key is inside; everyone else waits at the door. That's exactly right when threads are changing shared data, because two writers editing the same thing at the same time corrupts it. But it's wasteful when threads only want to read. Reading doesn't change anything, so ten threads reading the same list can't possibly interfere with each other. A plain mutex makes them queue anyway.
A read-write lock fixes that by giving the same data two doors instead of one. readLock() is the shared door: any number of threads can be holding it at the same time. writeLock() is the exclusive door: only one thread gets it, and only while nobody at all is holding the read lock. The rule in one breath is many readers or one writer, never both.
Picture a library noticeboard. A crowd can stand in front of it reading the same poster — nobody's reading spoils anybody else's. When the curator comes to replace the poster, everyone has to step back and wait outside, and the curator works alone, because a half-peeled poster is not something you want people reading. The moment the new poster is up, the crowd floods back in. That is a read-write lock, complete with its timing rules.
The one sentence to remember
Many readers or one writer, never both. A read-write lock only pays for itself when reads massively outnumber writes — otherwise its extra bookkeeping makes it slower than the plain mutex it replaced.
Where you've already met it
Any in-memory thing that is read constantly and updated rarely: a config cache reloaded once a minute but read on every request, a service's routing table, a product catalogue held in memory, a permissions map, a DNS cache. Databases use the same idea at a bigger scale — shared locks for SELECT, exclusive locks for UPDATE.
Mechanics
How it works
Two doors, four rules
- A reader may enter if no writer is inside. Other readers are irrelevant — they don't block each other, because reading is not a change.
- A writer may enter only when the room is completely empty — no readers, no other writer. Exclusive means exclusive.
- A waiting writer must wait for the readers to drain. It cannot kick anyone out; it sits in the queue until the last current reader releases its lock.
- While a writer is waiting or working, new readers are held out. Otherwise a steady trickle of readers would keep the room permanently non-empty and the writer would never get in.
That last rule is the interesting one. It's why the prototype shows a reader queueing with the tag waiting: writer queued even though nobody is technically writing yet. The lock is deliberately holding the door shut so the writer eventually gets its turn.
ReadWriteLock rw = new ReentrantReadWriteLock();
Map<String, String> config = new HashMap<>();
String get(String key) {
rw.readLock().lock(); // SHARED — many threads at once
try { return config.get(key); }
finally { rw.readLock().unlock(); } // always in a finally
}
void reload(Map<String, String> fresh) {
rw.writeLock().lock(); // EXCLUSIVE — waits for readers to drain
try { config = fresh; }
finally { rw.writeLock().unlock(); }
}Note the shape: lock() outside the try, unlock() inside a finally. Unlike Java's synchronized block, an explicit lock is not released for you when an exception flies out — forget the finally and the lock is held forever, which freezes every other thread. This is the single most common bug in hand-written lock code.
When it pays off — and when it doesn't
A read-write lock is not free. It has to track how many readers are inside, wake a writer when the count hits zero, and decide who goes next — that's more state and more atomic operations than a mutex, which only tracks locked-or-not. You are paying that overhead on every single acquire, including all the reads.
- It pays off when reads hugely outnumber writes and the critical sections are long enough for the parallelism to matter — an in-memory catalogue read a thousand times per update, a routing table, a cache of parsed config.
- It does not pay off when writes are frequent: the write lock serialises everything anyway, so you've bought the bookkeeping and none of the concurrency.
- It does not pay off for very short critical sections — reading one field, incrementing one counter. The lock's own overhead dwarfs the work being protected, and a plain mutex (or an atomic variable) is measurably faster.
The honest default is: start with a plain mutex. Move to a read-write lock only when you've measured a read-heavy contention problem. Swapping one in "because it sounds faster" often makes things slower.
Starvation: who gets to go next?
If the lock simply let readers in whenever no writer was currently inside, a busy system could keep at least one reader in the room forever, and the writer would sit in the queue indefinitely. That's writer starvation — a thread that is never technically deadlocked, just permanently unlucky. The fix is a policy:
- Read-preferring — readers jump straight in whenever no writer holds the lock. Best raw read throughput, but writers can starve.
- Write-preferring — once a writer is waiting, new readers queue behind it. Writers always get their turn; read throughput dips slightly. This is what the prototype does.
- Fair (FIFO) — everyone is served in arrival order. No starvation for anyone, but readers that could have shared a turn are split up, so throughput drops. Java's
new ReentrantReadWriteLock(true)gives you this.
Downgrading is fine — upgrading deadlocks
Downgrading means going from the write lock to the read lock: while still holding the write lock, acquire the read lock, then release the write lock. This is legal and useful — you finish your update and keep reading the fresh value without ever letting go and racing someone else for it.
The classic gotcha: never upgrade a read lock
Upgrading — trying to take the write lock while you still hold the read lock — deadlocks in most implementations, including Java's ReentrantReadWriteLock. The write lock waits for all readers to leave, and you are one of those readers, waiting for yourself. Worse, if two threads try it at once you get a mutual deadlock. Release the read lock first, take the write lock, then re-check your condition — the world may have changed in the gap. The prototype's ⚠️ Upgrade R1→W button shows exactly this: the room turns red and never recovers.
What your language gives you
- Java —
ReentrantReadWriteLock(optionally fair) is the standard one.StampedLockis faster but not reentrant, and adds an optimistic read: you read without locking, then callvalidate()to ask "did a writer touch this while I was reading?" — if it says no, you paid almost nothing; if it says yes, you retry with a real read lock. - C++ —
std::shared_mutex(C++17), taken withstd::shared_lockfor readers andstd::unique_lockfor writers. - Go —
sync.RWMutex, withRLock()/RUnlock()for readers andLock()/Unlock()for writers. - Python — the standard library has no read-write lock. You either build one from
threading.Condition(about twenty lines) or reach for a library. In CPython the GIL already serialises bytecode, so the win is smaller than you'd expect for pure-Python work.
The alternative: don't share mutable state at all
Before adding a read-write lock, consider making the data immutable and swapping whole snapshots instead. A writer builds a brand-new copy, then atomically publishes it by reassigning one reference; readers just grab the current reference and read it with no lock at all. That's copy-on-write — CopyOnWriteArrayList in Java, or a plain AtomicReference to an immutable map. Readers become completely free and can never see a half-finished update; the cost is a full copy per write, so it only suits data that is written rarely. The immutability lesson later in this phase goes deeper.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
A room with one 📄 Shared document v3 inside, five actors in the tray (👓 R1 R2 R3 and ✍️ W1 W2), and a mode switch at the top. Start on 🔒 Plain mutex and click R1, R2, R3 — only one gets in, the other two sit in the amber waiting queue tagged waiting: lock held, even though all three only want to look. Flip to 👓✍️ Read-Write lock, hit ↺ Reset, and click the same three: now readers inside: 3, all green, all at once. Click ✍️ W1 and it queues with waiting: 3 readers — a writer must wait for every reader to drain. Hit ✅ Release all, W1 walks in alone, and releasing it ticks the document v3 → v4. Click R2 while W1 is inside and it queues too — writers block new readers. Finally, with R1 inside, press ⚠️ Upgrade R1→W: the room turns red and stays red, because upgrading a read lock to a write lock deadlocks.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Feel the waste of a plain mutex
Click 🔒 Plain mutex, then click 👓 R1, 👓 R2 and 👓 R3 in the tray. Only R1 gets into the room; R2 and R3 drop into the amber waiting queue tagged waiting: lock held. Watch the caption: readers inside: 1, waiting: 2. Three threads that only wanted to look are now standing in line. Press ✅ Release all repeatedly and count how slowly reads served climbs — one per turn.
The money shot: readers share
Hit ↺ Reset, switch to 👓✍️ Read-Write lock, and click the same three readers. All three sit inside together — readers inside: 3, waiting: 0, everything green. Now press ✅ Release all: reads served jumps by 3 in a single turn. Same clicks, three times the work, because reading doesn't change the document and so readers never conflict.
Writers drain, block, and then break it
Click 👓 R1, R2 and R3 back into the room, then click ✍️ W1 — it queues with waiting: 3 readers, because a writer needs the room empty. Press ✅ Release all and W1 walks in alone; click 👓 R2 now and it queues with waiting: writer inside. Click W1's ⏏ leave chip and the document ticks v3 → v4 while R2 is admitted. Finally, reset, put 👓 R1 inside, and press ⚠️ Upgrade R1→W: the room goes red and stays red — upgrading a read lock to a write lock deadlocks, because R1 is waiting for itself to leave.
In practice
When to use it — and what trips people up
Reach for a read-write lock when...
- Reads vastly outnumber writes — a rough rule of thumb is 10:1 or better. A config cache, a routing table, an in-memory catalogue, a permissions map.
- The critical section is long enough to matter — parsing, scanning a collection, serialising a response. Letting ten of those run in parallel is a real win.
- The data must stay mutable in place — you can't just publish an immutable snapshot because the structure is large and updates are partial.
Skip it when...
- Writes are common — the write lock serialises everything anyway, so you pay the extra bookkeeping for no parallelism. A plain mutex is simpler and faster.
- The critical section is tiny — one field read, one counter bump. Use a plain mutex, or an atomic variable and skip locking entirely.
- The data is small and rarely written — copy-on-write or an immutable snapshot behind an atomic reference gives readers a completely lock-free path.
- You haven't measured — reach for the simple mutex first and only upgrade when a profiler shows read contention.
What it gives you
- Real read parallelism — every reader can be inside at once, so read-heavy workloads stop queueing for no reason.
- Correctness is unchanged — writers are still fully exclusive, so nobody ever observes a half-finished update.
- It expresses intent — readLock() versus writeLock() documents at the call site whether this code mutates the data.
- Tunable policy — most implementations offer fair or write-preferring modes, so you can trade a little throughput for no starvation.
Common mistakes
- Slower than a mutex under write-heavy or very short critical sections — the reader bookkeeping costs more than it saves.
- Starvation risk — a naive read-preferring lock can leave a writer waiting forever behind a stream of readers.
- Upgrading deadlocks — taking the write lock while holding the read lock is a classic, silent hang in Java, C++ and Go alike.
- More ways to get it wrong — two lock types, a mandatory finally/defer on each, and no compiler check that a read path never mutates.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.util.*;
import java.util.concurrent.locks.*;
/** A config map read on every request, reloaded once a minute. */
public class ConfigCache {
// fair=true would serve everyone FIFO (no starvation, less throughput)
private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
private final Lock read = rw.readLock(); // SHARED door
private final Lock write = rw.writeLock(); // EXCLUSIVE door
private Map<String, String> config = new HashMap<>();
public String get(String key) {
read.lock(); // many threads at once
try {
return config.get(key);
} finally {
read.unlock(); // ALWAYS in a finally
}
}
public void reload(Map<String, String> fresh) {
write.lock(); // waits for readers to drain,
try { // and blocks new ones meanwhile
config = new HashMap<>(fresh);
} finally {
write.unlock();
}
}
/** Legal: write -> read. Take read BEFORE releasing write. */
public String reloadAndPeek(Map<String, String> fresh, String key) {
write.lock();
try {
config = new HashMap<>(fresh);
read.lock(); // downgrade: still holding write
} finally {
write.unlock(); // now only the read lock is held
}
try {
return config.get(key);
} finally {
read.unlock();
}
}
// DO NOT DO THIS — upgrading deadlocks: the write lock waits for all
// readers to leave, and this thread is one of them, waiting for itself.
// read.lock(); write.lock(); // <- hangs forever
}References & further reading
7 sources- Docsen.wikipedia.org
Readers–writer lock — Wikipedia
The concept from first principles: the shared/exclusive rules, read-preferring vs write-preferring policies, and the starvation trade-off between them.
- Docsdocs.oracle.com
ReentrantReadWriteLock — Java SE API docs
The reference implementation, including the fair/non-fair modes and an official worked example of lock downgrading (write to read).
- Docsdocs.oracle.com
StampedLock — Java SE API docs
The faster, non-reentrant cousin. Read the optimistic-read example: read first, validate() afterwards, fall back to a real read lock only if a writer intervened.
- Articlejenkov.com
Read / Write Locks in Java — Jenkov
Builds a read-write lock by hand from wait/notify, step by step — the clearest way to see why the reader count and the waiting-writer count both have to exist.
- Docsen.cppreference.com
std::shared_mutex — cppreference
The C++17 primitive, with the shared_lock/unique_lock RAII wrappers and the explicit warning that it is neither recursive nor upgradable.
- Docspkg.go.dev
sync.RWMutex — Go package documentation
Go's version in a few paragraphs, including the crucial note that a blocked Lock() stops new RLock() calls from succeeding, so recursive read locking can deadlock.
- Bookjcip.net
Java Concurrency in Practice — Goetz et al.
The standard text on this material. Chapter 13 covers explicit locks and ReadWriteLock, and is blunt about when the extra complexity is not worth it.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 06
Why can many readers safely hold the lock at the same time?
question 02 / 06
Three readers are inside and a writer requests the write lock. What happens?
question 03 / 06
A counter is incremented by a thousand threads and read occasionally, each critical section being a single field update. What should you use?
question 04 / 06
A thread holding the read lock calls writeLock().lock() without releasing the read lock first. What happens?
question 05 / 06
What is writer starvation, and how do real implementations avoid it?
question 06 / 06
Your in-memory routing table is read constantly and replaced once a minute. Besides a read-write lock, what alternative removes reader locking entirely?
0/6 answered