Skip to main content
PRISM

Reader-writer locks

intermediate · commonly asked

Many readers or one writer, never both. Switch to the unlocked variant and watch a reader come away with a record that never existed.

Enumerating the interleavings…

The problem it solves

Two threads reading the same value cannot corrupt it. Making them take turns is throughput given away for nothing.

A plain mutex does exactly that: it excludes everybody from everybody, which is correct and, on a read-heavy workload, wasteful. If ninety-five percent of your operations are reads, a mutex serialises ninety-five percent of your traffic to protect against the five percent that could actually conflict.

A reader-writer lock encodes the asymmetry. Shared access for readers — any number at once — and exclusive access for writers. It is the right shape for configuration, caches, routing tables, and anything else read constantly and updated occasionally.

It also has two failure modes worth knowing before you reach for it, and they are the substance of this page: writers can wait indefinitely, and the temptation to skip the lock on the read side produces a bug that is genuinely surprising the first time you meet it.

The mechanism

The lock has three states: free, held by any number of readers, or held by exactly one writer. A reader may enter while other readers are inside. A writer must wait for every current reader to leave, and while a writer holds it nobody else may enter at all.

That “every current reader” is where writer starvation comes from. Under continuous read traffic, readers arriving after the writer began waiting can still be admitted — the lock is free of writers, so why not — and the writer waits for a gap that never comes. Most implementations therefore offer a writer-preference mode, which blocks new readers once a writer is queued. That fixes writer starvation and creates reader starvation under continuous write traffic, and there is no arrangement that avoids both.

The other thing to understand is what a writer’s exclusivity is for. The writer updates several fields, and between those updates the record is inconsistent — that is what a critical section is. Exclusivity means no reader can be inside during that window. Remove the lock from the read side and readers can be.

What the enumeration shows

Two readers and one writer over a record with two fields that must agree.

With both sides locked: 0 of 1,850 interleavings see a mismatch, and the whole space is enumerated so that is a proof rather than an absence of failures. Step through and watch the state panel show two readers holding the lock simultaneously — not a queue. That is the feature.

Now switch to readers-writer-unlocked, where the reads skip the lock because “it is only a read”. 1,141 of 2,500 interleavings come away with a torn record: the new value in one field and the old value in the other, a combination that never existed at any instant. Nothing in memory is corrupted. The corruption is entirely in what the reader believed.

Nearly half the orderings. This is not an exotic window.

The numbers worth carrying

  • Locked reads: 0 torn reads. Unlocked reads: 1,141 of 2,500.
  • A reader-writer lock beats a mutex when reads dominate and critical sections are long enough to matter. Below roughly a 90% read ratio, or for very short critical sections, a plain mutex is usually faster — the RW lock’s own bookkeeping (a reader count that every reader must atomically update) is more expensive than a simple lock, and that reader count is itself a contended cache line.
  • That last point is the one people miss: readLock() is not free and not read-only. It writes to shared state, so n concurrent readers generate n coherence events on the counter — see false sharing for why that is expensive.

Where it breaks down

Upgrading deadlocks. A thread holding a read lock that tries to take the write lock must wait for all readers to leave, including itself. Java’s ReentrantReadWriteLock forbids upgrading outright; most other implementations deadlock. Downgrading — write lock to read lock — is safe and supported, and is the standard way to publish an update and then keep reading it.

Reads that are not read-only. A “read” that lazily initialises a cache, updates an access timestamp for LRU, or increments a hit counter is a write. This is a common source of corruption in code that looks correctly locked.

Starvation, in whichever direction you choose. Reader-preference starves writers; writer-preference starves readers. Java’s implementation is non-fair by default and offers a fair mode with the usual throughput cost — see starvation.

It does not help if the critical section is tiny. Reading one word is better served by an atomic or a volatile field than by a lock of any kind.

What people get wrong

“It is only a read, so it does not need the lock.” The most expensive sentence on this page. 1,141 of 2,500.

“Reads are atomic so I am fine.” Each individual field read may be atomic. Reading two fields is two operations, and the whole problem is that they can straddle an update. Atomicity per field is not atomicity per record.

“An RW lock is always better for read-heavy work.” Below about 90% reads, or for short critical sections, a mutex usually wins. Measure; the crossover is workload-specific and the RW lock’s bookkeeping is real.

“I can upgrade from read to write.” Almost nowhere, and where you can it is a distinct API. Assuming it works is a deadlock.

In production

ReentrantReadWriteLock and the far better StampedLock in Java, std::shared_mutex in C++17, sync.RWMutex in Go, RwLock in Rust, threading has no built-in one in Python.

StampedLock deserves particular attention because it offers optimistic reading, which is the pattern that actually solves the read-heavy case: take a stamp, read the fields without any lock at all, then validate that the stamp is still current. If it is, the read was consistent and cost nothing — no shared write, no coherence traffic. If not, fall back to a real read lock and retry. This is the same “verify nothing changed” idea as compare-and-swap, applied to a read instead of a write, and it removes the contended reader count that makes ordinary RW locks disappointing.

The alternatives worth considering before reaching for any of them: immutability with an atomic reference swap gives readers a consistent snapshot with no lock and no validation, at the cost of allocating on write — which for configuration read millions of times and updated hourly is obviously the right trade. Copy-on-write collections are the packaged version of that.

The follow-up questions

“When would you use a reader-writer lock?” — Read-dominated with a long enough critical section to matter. Then the caveat that below ~90% reads a mutex usually wins.

“Can a reader see an inconsistent record?” — Not if it takes the read lock; certainly if it does not. Give the two-field example.

“Can you upgrade a read lock to a write lock?” — No, and say why: you would be waiting for yourself.

“Reads dominate and the RW lock is slower than a mutex. Why?” — The reader count is shared mutable state, so every reader writes. Then propose optimistic reads or immutability.

In an interview

The follow-up to "our workload is read-heavy", and a good test of whether someone knows that a read can be unsafe without writing anything.

  • RW lock
  • torn read
  • shared access
  • writer starvation

Run these next

The rest of coordination