Intermediate12 min readConcurrency & Thread Safetylive prototype

Read-Write locks

A plain mutex lets one thread in at a time, even when all they want to do is look. But reading shared data doesn't change it — so readers can't hurt each other. A read-write lock has two doors: a shared readLock() that many threads can hold at once, and an exclusive writeLock() that only one thread can hold, and only when nobody is reading. Think of a library noticeboard: any number of people can read the poster together, but when the curator swaps it, everyone steps back.

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.

Cache.java
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

  • JavaReentrantReadWriteLock (optionally fair) is the standard one. StampedLock is faster but not reentrant, and adds an optimistic read: you read without locking, then call validate() 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 with std::shared_lock for readers and std::unique_lock for writers.
  • Gosync.RWMutex, with RLock()/RUnlock() for readers and Lock()/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-writeCopyOnWriteArrayList 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.

try 01

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.

try 02

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.

try 03

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

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