The Readers-Writers Problem

Picture a shared in-memory cache, like a DNS resolver's record table. It gets hit by thousands of lookups per second. Most operations only read entries. Occasionally one thread must update a stale record. Locking the entire table for every lookup wastes enormous concurrency. Reads never corrupt data when they overlap with other reads. Yet a write overlapping with anything — a read or another write — can produce torn, inconsistent results. The Readers-Writers problem asks how to synchronize access. Many readers should proceed simultaneously. Writers still need exclusive, consistent access. This is not an academic curiosity. Database engines, filesystem metadata caches, routing tables, and language runtimes all solve variants of this exact problem. Examples include Java's `ReentrantReadWriteLock` and the Linux kernel's `seqlock` and RCU mechanisms. Getting it right matters. The naive fix — a single mutex around the whole resource — turns a read-heavy, embarrassingly parallel workload into a serial bottleneck. Getting it wrong in the other direction is also risky. Favoring readers too aggressively can let writers wait forever. The big picture is that Readers-Writers is a case study. It shows priority policies built on top of mutual exclusion primitives. The same low-level tool, semaphores, can implement three qualitatively different fairness contracts. These are readers-preferred, writers-preferred, and fair (FIFO-ish) service. Each contract has a clean pseudocode solution. Each also has a correctness argument, and a distinct failure mode. This article builds the classic solutions from first principles. It proves each satisfies the required invariants. It traces a worked scenario with overlapping readers and writers. It also catalogs the traps — especially the starvation traps. These traps make this topic the natural bridge into Starvation and Livelock.

Key takeaways
  • Readers-Writers allows many concurrent readers but requires writers to have fully exclusive access.
  • The core safety invariant: no writer runs concurrently with any reader or any other writer.
  • The first (classic) solution favors readers, which can starve writers indefinitely.
  • The second classic solution favors writers, which can starve readers indefinitely.
  • A fair solution enforces roughly FIFO ordering between readers and writers using an entry ticket.
  • All three are built from ordinary counting/binary semaphores plus a reader-count variable.
The core insight

Readers-Writers synchronization trades a single mutual-exclusion lock for a policy that lets readers share access while writers demand exclusivity — and the choice of policy determines who can starve.

The idea

The Readers-Writers problem models a shared resource accessed by two classes of processes: readers, which only inspect the resource and never modify it, and writers, which modify the resource and require it to be in a stable, unseen-by-others state while doing so. The synchronization goal is captured by two invariants that any correct solution must guarantee: 1. Writer exclusivity: while a writer holds the resource, no reader and no other writer may access it. 2. Reader concurrency: any number of readers may access the resource simultaneously, provided no writer is currently writing. A solution that only enforces invariant (1) via a single mutex, wrapping both readers and writers in the same critical section, is safe but not efficient — it destroys the concurrency that readers could have enjoyed. A correct Readers-Writers solution must let overlapping reads proceed in parallel while still serializing writes against everything else. There is a third, often underspecified dimension: fairness (liveness). Because readers can keep arriving and overlapping with each other, a writer might never find a moment when the reader count drops to zero — this is the seed of writer starvation, and symmetric reasoning gives reader starvation under writer-favoring policies. This is why the problem is traditionally presented in three variants:

  • First Readers-Writers Problem (readers-preferred): no reader should wait if the resource is currently open for reading, even if a writer is queued. Writers may starve.
  • Second Readers-Writers Problem (writers-preferred): once a writer is queued, no new reader may start ahead of it. Readers may starve.
  • Third Readers-Writers Problem (fair / no-starvation): readers and writers are served in roughly arrival order — neither class can indefinitely block the other.

How it works

All three variants are built from the same primitive toolbox: binary semaphores or mutexes for mutual exclusion, plus a shared counter tracking how many readers are currently active. The key trick is that only the first reader to arrive needs to lock out writers, and only the last reader to leave needs to unlock them — intermediate readers just increment/decrement a counter under a short-lived mutex.

Variant 1 — Readers-preferred

``` shared: integer read_count = 0 semaphore mutex = 1 // protects read_count semaphore resource = 1 // controls access to the shared resource Reader(): wait(mutex) read_count = read_count + 1 if read_count == 1: wait(resource) // first reader locks out writers signal(mutex) // -- critical section: read the resource -- wait(mutex) read_count = read_count - 1 if read_count == 0: signal(resource) // last reader lets writers in signal(mutex) Writer(): wait(resource) // -- critical section: write the resource -- signal(resource) ``` Walk through the logic. `resource` is a binary semaphore acting as the "writer lock" — whoever holds it has exclusive access. Readers do not each acquire `resource`; instead they acquire it collectively, using `read_count` as a reference count. The first reader to arrive when `read_count` goes from 0 to 1 grabs `resource`, blocking any writer. Every subsequent reader just increments the counter and proceeds directly to reading — no wait on `resource` at all, hence unlimited reader concurrency. The last reader to leave, dropping `read_count` from 1 to 0, releases `resource`. As long as at least one reader is active, `resource` stays held, so a writer that arrives mid-stream must wait — and if new readers keep trickling in before the count hits zero, the writer waits indefinitely. This is the textbook readers-preferred bias.

Variant 2 — Writers-preferred

To flip the bias, add a second gate: once a writer is waiting, new readers must not slip in ahead of it. ``` shared: integer read_count = 0 integer write_count = 0 semaphore mutex1 = 1 // protects read_count semaphore mutex2 = 1 // protects write_count semaphore resource = 1 // exclusive resource lock semaphore service_queue = 1 // gate that blocks new readers when a writer is waiting Reader(): wait(service_queue) // must pass this gate to even try wait(mutex1) read_count = read_count + 1 if read_count == 1: wait(resource) signal(mutex1) signal(service_queue) // -- critical section: read -- wait(mutex1) read_count = read_count - 1 if read_count == 0: signal(resource) signal(mutex1) Writer(): wait(mutex2) write_count = write_count + 1 if write_count == 1: wait(service_queue) // first waiting writer closes the gate to new readers signal(mutex2) wait(resource) // -- critical section: write -- signal(resource) wait(mutex2) write_count = write_count - 1 if write_count == 0: signal(service_queue) // last writer reopens the gate signal(mutex2) ``` Here `service_queue` is the writer's weapon. As soon as one writer is waiting (`write_count` goes 0→1), it grabs `service_queue`, which every new reader must pass through before even registering itself. Readers already past the gate and already reading finish normally (the writer still waits for `resource` to be free), but no new reader can join the party once a writer is queued. When the last queued writer finishes (`write_count` back to 0), the gate reopens for readers. This inverts the starvation risk: a continuous stream of writers can now starve readers.

Variant 3 — Fair (no-starvation) solution

The fair solution enforces arrival-order service using a single additional mutex acting as a ticket turnstile: every arriving thread, reader or writer, must pass through it in order, and it is held only long enough to be granted a place in line. ``` shared: integer read_count = 0 semaphore mutex = 1 // protects read_count semaphore resource = 1 // exclusive resource lock semaphore turnstile = 1 // FIFO-ish entry gate for both readers and writers Reader(): wait(turnstile) // take a ticket, in arrival order signal(turnstile) // release it immediately after passing wait(mutex) read_count = read_count + 1 if read_count == 1: wait(resource) signal(mutex) // -- critical section: read -- wait(mutex) read_count = read_count - 1 if read_count == 0: signal(resource) signal(mutex) Writer(): wait(turnstile) // take a ticket, in arrival order wait(resource) // hold turnstile until resource acquired signal(turnstile) // -- critical section: write -- signal(resource) ``` The insight: `turnstile` is a single-slot gate that any thread — reader or writer — must acquire before it is allowed to proceed toward `resource`. A writer holds `turnstile` while it waits for `resource`, which blocks any later-arriving reader from jumping the queue past it (readers immediately release `turnstile` after passing, but they must still wait behind a writer currently holding it). Because every thread queues on the same semaphore in arrival order (most semaphore implementations service waiters FIFO), no class can be perpetually skipped: any writer or reader that requests access will be granted it within a bounded number of turns, once currently-active operations finish.

Figure
State and gate structure of the fair Readers-Writers solution
Fair Readers-Writers: turnstile + resource lock Readers arriving Writers arriving turnstile (FIFO gate) mutex protects read_count resource (exclusive lock) held by writer, or by first-in reader first reader waits(resource) Critical Section (shared data)

Complexity & correctness

Time complexity. Each `Reader()` call performs a constant number of semaphore operations (at most two `wait`/`signal` pairs on `mutex`, and occasionally one pair on `resource` or `turnstile`), so per-operation overhead is O(1) regardless of how many readers are concurrently active. This is the entire point: the cost of admitting a reader does not scale with the number of readers already inside, unlike a design that made every reader individually acquire the full resource lock. `Writer()` similarly performs O(1) semaphore operations. Contention (time spent blocked) is workload-dependent, not part of the asymptotic operation count, but worth noting qualitatively: under a read-heavy load with readers-preferred, writer waiting time is unbounded in the worst case (see pitfalls); under the fair solution, waiting time for any thread is bounded by the number of threads that arrived strictly before it, which is finite in any real system.

Space complexity. Each variant needs O(1) additional shared state beyond the resource itself: one or two integers (`read_count`, possibly `write_count`) and two or three semaphores. This is independent of the number of readers or writers in the system — no per-thread bookkeeping is required by the synchronization mechanism itself (though a queue-based real implementation may add O(n) space for explicit ticket tracking).

Correctness — safety. The core invariant to prove is mutual exclusion of writers against everyone. Consider variant 1. `resource` is a binary semaphore, so at most one holder exists at any time. A writer only proceeds past `wait(resource)` if it becomes that holder. Readers as a group only proceed past the reader gate when the first reader (in the `read_count` 0→1 transition) has itself become the holder of `resource`; all subsequent readers piggyback on that same held permit without re-acquiring it. Since `resource` has exactly one holder at a time, either that holder is "the readers as a group" (with `read_count ≥ 1`) or it is a single writer — never both. This is enforced because `read_count` mutations happen under `mutex`, so the 0→1 and 1→0 transitions are atomic and cannot race with each other or with a writer's `wait(resource)`. Hence writer-exclusivity holds by induction on the sequence of semaphore operations: initially `resource` is free (1), and every `wait`/`signal` pair preserves the invariant that at most one logical class (readers-as-a-group or a single writer) holds it. The same style of argument applies to variants 2 and 3, with the additional gates (`service_queue`, `turnstile`) only affecting who is allowed to attempt the acquisition, never weakening the exclusivity guarantee provided by `resource` itself. This is a useful general principle: safety is guaranteed by the innermost lock (`resource`); fairness is layered on top via outer gates that shape arrival order without ever bypassing the innermost lock.

Correctness — liveness / fairness. This is where the three variants diverge, and honestly stating the tradeoff is essential:

  • Variant 1 (readers-preferred) guarantees that any reader arriving while reading is in progress is served promptly (no reader waits behind a writer that hasn't yet started). It does not guarantee writers ever get served if readers arrive faster than they leave — a continuously-refreshed nonempty set of readers keeps `resource` held forever from the writer's perspective.
  • Variant 2 (writers-preferred) guarantees writers are served promptly once queued (no writer waits behind a "new" reader that arrived after it). It does not guarantee readers ever get served if writers arrive back-to-back — the gate `service_queue` can stay closed indefinitely.
  • Variant 3 (fair) guarantees that any thread's wait time is bounded by the number of threads queued strictly ahead of it at arrival, assuming the underlying semaphore implementation wakes waiters in FIFO order (a common but not universal guarantee — POSIX does not mandate FIFO wakeup for semaphores, so real deployments sometimes need explicit ticket queues to guarantee this rigorously).

Tradeoffs, stated plainly. More fairness costs more synchronization overhead and reduces peak reader throughput, because the turnstile forces every reader to serialize briefly even when no writer is present. Readers-preferred maximizes reader throughput at the cost of writer starvation risk. Writers-preferred protects data freshness (writers get in fast) at the cost of reader starvation risk. There is no free lunch — the correct choice is workload-dependent, which is precisely why real systems (databases, kernels) expose configurable or hybrid policies rather than picking one variant universally.

Worked example

Consider the fair (variant 3) solution with three readers R1, R2, R3 and one writer W, arriving in this order: R1, W, R2, R3. Assume each thread takes a small, nonzero amount of time in its critical section, and semaphore waiters are released in FIFO arrival order.

TimeEventturnstileresourceread_countOutcome
t0R1 arrivesR1 takes & releases turnstileR1 takes resource (0→1 reader)1R1 reading
t1W arrivesW blocks on turnstile (held? no — R1 released it already, so W takes turnstile)W blocks on resource (held by R1)1W waits, holding turnstile
t2R2 arrivesR2 blocks on turnstile (held by W)—1R2 queued behind W
t3R3 arrivesR3 blocks on turnstile (held by W)—1R3 queued behind W, behind R2
t4R1 finishes reading—R1 releases resource (read_count 1→0)0resource now free
t5W acquires resourceW releases turnstileW holds resource0W writing; R2, R3 still blocked on turnstile
t6W finishes writing—W releases resource0resource free
t7R2 acquires turnstileR2 takes & releases turnstileR2 takes resource (0→1)1R2 reading
t8R3 acquires turnstileR3 takes & releases turnstileR3 joins (read_count 1→2, no wait needed)2R2, R3 reading concurrently
t9R2, R3 finish—last one releases resource0idle

Trace the key subtlety at t1–t3: because W grabs `turnstile` and does not release it until it has also acquired `resource`, R2 and R3 — even though they only want to read — cannot slip past W. This is exactly the fairness property: W is served before R2 and R3 despite R1 having started first (R1 was already in progress and is allowed to finish, which is unavoidable since preemption mid-critical-section is not assumed). Contrast this with variant 1 (readers-preferred): at t2 and t3, R2 and R3 would not block on any writer-related gate at all — they would join `read_count` immediately (2, then 3), potentially keeping `resource` held by "the readers" indefinitely if a R4, R5, ... kept arriving, starving W completely. The fair solution's turnstile is precisely the mechanism that prevents that outcome.

Figure
Timeline trace: fair solution vs readers-preferred for the same arrival order
Arrival order: R1, W, R2, R3 Fair: R1 reads W writes R2 + R3 read together Readers-pref: R1 reads R2 joins R3 joins W still waiting... (more readers could keep arriving) W blocked Fair: W is served right after R1 finishes, before R2/R3 join. Readers-preferred: W waits behind an ever-growing reader group — starvation risk.

Common pitfalls & traps

PitfallWhy it happensFix
Wrapping the whole resource in one mutexSimplicity bias; "just make it safe"Use the counter + resource-lock pattern to let reads overlap
Forgetting to protect `read_count` itselfTreating it like an ordinary integer instead of shared stateGuard every `read_count` update with its own `mutex`
Writer starvation in readers-preferred code used under heavy read loadThe 0→1 / 1→0 transition never coincides with an idle windowSwitch to writers-preferred or fair variant if writers matter
Reader starvation in writers-preferred code used under heavy write load`service_queue` gate stays closed as writers keep re-queuingUse the fair variant with a turnstile
Releasing `resource` before decrementing `read_count`, or vice versa, inconsistentlyRace between the "last reader" check and the actual releaseAlways update the counter and check-for-zero atomically inside the same `mutex` critical section
Assuming POSIX semaphores wake waiters FIFOConflating "semaphore" with "fair queue"Use explicit ticket/queue structures if strict ordering must be guaranteed
Upgrading a reader lock to a writer lock in placeTwo readers both attempt to upgrade simultaneously → deadlockRelease the read lock fully before requesting the write lock, and re-check state after reacquiring

Pioneers & history

The Readers-Writers problem was first formalized in the context of early operating systems research on structuring concurrent access to shared data structures, growing directly out of the broader program of using semaphores to express classic synchronization patterns.

Origins · 1 minEdsger W. Dijkstra and how this was discovered
EW
Edsger W. Dijkstra
1930–2002 · Pioneer of semaphores and structured concurrent programming, whose P/V operations underpin every solution to Readers-Writers

Dijkstra introduced the semaphore as a general synchronization primitive in the mid-1960s while working on the THE multiprogramming system. His P (wait) and V (signal) operations gave programmers a clean, provably correct way to express both mutual exclusion and signaling — exactly the two ingredients combined in every Readers-Writers solution: `mutex` for protecting the counter, and `resource` for exclusive access. Dijkstra also emphasized reasoning about concurrent programs via invariants, the same technique used above to prove writer-exclusivity holds at every step.

History
1965
— Dijkstra introduces semaphores and the P/V operations in the THE operating system project.
1971
— Courtois, Heymans, and Parnas formally state the First and Second Readers-Writers Problems and give canonical semaphore-based solutions.
1978
— CSP and related formalisms (Hoare) provide alternative language-based ways to express reader/writer coordination.
1980
s — Database systems popularize reader-writer locks as a standard concurrency-control primitive for tables and pages.
2000
s — Read-Copy-Update (RCU) in the Linux kernel generalizes the readers-preferred idea to near-zero-overhead lock-free reads.

Where this matters

Reader-writer locks are a standard primitive in nearly every mainstream concurrency library: POSIX `pthread_rwlock_t`, Java's `java.util.concurrent.locks.ReentrantReadWriteLock`, C++'s `std::shared_mutex`, and .NET's `ReaderWriterLockSlim` all expose exactly the readers-share / writers-exclusive contract described here, typically with a configurable or writer-preferring fairness policy to avoid the classic starvation trap. Relational databases use reader-writer locking (often multi-granularity, at the row, page, and table level) to let concurrent `SELECT` queries proceed while an `UPDATE` waits for exclusive access to the rows it touches. Filesystems and kernel data structures use reader-writer spinlocks and Read-Copy-Update (RCU) to let many CPU cores traverse routing tables or process lists concurrently while updates are rare and serialized. Version control systems and configuration-management tools apply the same logic at a coarser grain: many clients can read a repository's HEAD state concurrently, but a commit that changes it needs exclusivity over the update. Understanding this problem is also the natural stepping stone to reasoning rigorously about Starvation and Livelock, since the readers-preferred and writers-preferred solutions are the cleanest concrete examples of a synchronization scheme that is perfectly safe yet fails to be live for one class of participants — a theme that recurs throughout concurrent systems design and connects back to the guarantees (or lack of them) offered by the underlying semaphore implementation itself.

Ask the tutor: Can you show how the fair solution's guarantees break down if the underlying semaphore does not wake waiters in FIFO order?

Why not just wrap the whole critical section in one mutex for both readers and writers?

That approach is safe but throws away the entire benefit of the problem's structure. A single mutex forces every reader to wait for every other reader, even though concurrent reads never conflict with each other — only with writes. In a read-heavy workload (the common case for caches, DNS tables, routing tables), this collapses potential full parallelism among readers into strict serialization, dramatically hurting throughput and latency. The whole point of the Readers-Writers pattern is to let the reference-counting trick (`read_count` plus a shared `resource` lock) grant the first reader exclusive access on behalf of the whole reading group, while every later reader piggybacks on that access for free.

Can a reader and a writer ever legitimately run at the same time?

No — that would violate the core safety invariant. If a writer is mid-update, the data structure may be in a temporarily inconsistent state (partially updated fields, a linked structure with a dangling pointer during a swap, etc.), so any concurrent reader could observe garbage or crash. All three variants enforce this via the `resource` binary semaphore: at most one holder exists, and that holder is either a single writer or "the readers as a group," never both. Some advanced techniques like RCU relax this by having readers observe a consistent snapshot while a writer builds a new version, but that's a fundamentally different technique, not a race condition tolerated by these classic solutions.

Which variant should I actually use in a real system?

It depends on the workload's freshness requirements. If writes are rare and staleness is tolerable for a bounded time (e.g., a metrics cache), readers-preferred maximizes throughput and the write-starvation risk is often acceptable in practice since writes are infrequent. If writes carry urgent correctness requirements (e.g., a configuration table that must reflect updates promptly, or a lock protecting safety-critical state), writers-preferred or the fair variant is safer. Many production libraries (e.g., Java's `ReentrantReadWriteLock`) default to a policy that can be configured to be fair, trading some throughput for starvation-freedom — a reasonable default when in doubt.

Does using a fair (FIFO) turnstile fully eliminate starvation in practice?

Only if the underlying semaphore implementation actually wakes waiting threads in FIFO order. The pseudocode's correctness argument assumes this. POSIX semaphores and many OS schedulers do not strictly guarantee FIFO wakeup — they may use priority-based or unspecified ordering. In rigorous real-world implementations, developers sometimes build an explicit ticket-queue (an incrementing "now serving" counter plus per-thread wait conditions) to guarantee true FIFO service rather than relying on semaphore wakeup order. The pseudocode here is correct under the common textbook assumption of FIFO-fair semaphores; it's worth checking your platform's actual guarantees before relying on it for hard starvation-freedom.

How does this problem connect to starvation and livelock more broadly?

Readers-Writers is the cleanest concrete illustration that a synchronization scheme can be perfectly safe (the mutual-exclusion invariant always holds) while still failing a liveness property (every waiting thread eventually proceeds). Readers-preferred and writers-preferred are both safe, provably so, yet each admits an execution history where one class of thread waits forever. This distinction between safety properties (nothing bad ever happens) and liveness properties (something good eventually happens) is the conceptual core of the broader study of starvation and livelock, where the same tension appears in scheduling, lock acquisition ordering, and distributed consensus.

What is the difference between a reader "upgrading" to a writer and just acquiring the write lock fresh?

An in-place upgrade means a thread holding a read lock tries to convert it directly into a write lock without releasing it first. This is dangerous: if two readers both attempt to upgrade simultaneously, each is waiting for the other to release its read lock, and neither can proceed — a deadlock. The safe pattern is to fully release the read lock, then explicitly request the write lock as a fresh acquisition, then re-validate any assumptions about the data (since it may have changed between the release and the new acquisition). Some specialized designs use a distinguished "upgradeable read lock" that only one thread may hold at a time, avoiding the two-upgraders deadlock, but that is an extension beyond the classic three variants.

Why does the writer in the writers-preferred solution use two separate semaphores, mutex2 and service_queue?

`mutex2` protects the `write_count` variable itself, exactly analogous to how `mutex` protects `read_count` for readers — it prevents a race when multiple writers concurrently increment or decrement `write_count`. `service_queue` is the actual gate that blocks new readers; it is acquired once, by the first writer to start queuing (when `write_count` goes 0→1), and released once, by the last writer to finish (when `write_count` returns to 0). This mirrors the reader-side pattern precisely: just as only the first/last reader touches `resource`, only the first/last writer touches `service_queue`, while `mutex2` guards the bookkeeping counter that determines those transitions.

Before this

Where this leads