Loading content...
The Readers Preference solution, also known as the First Readers-Writers Problem, embodies a fundamental design choice: maximize read throughput by never making readers wait for other readers. This approach treats writers as second-class citizens—they must wait for all active readers to finish, and continuous reader arrivals can indefinitely delay writers.
This sounds unfair, and it is. But for many real-world systems, it's exactly the right tradeoff. Read-heavy workloads dominate computing: web servers handling thousands of GET requests per second, databases processing analytics queries, caches serving repeated lookups. In these scenarios, reader concurrency directly translates to system throughput.
By the end of this page, you will understand: the complete Readers Preference solution with semaphores; why the solution requires two synchronization primitives; how the first reader/last reader pattern works; a rigorous proof of correctness; analysis of the writer starvation problem; and guidance on when this solution is appropriate.
The Readers Preference solution is built on a key insight: only the first reader and last reader need to interact with the writer-exclusion mechanism. Readers in between can simply proceed without additional synchronization overhead.
Core Strategy:
read_count)This strategy requires two synchronization mechanisms:
rw_mutex): Controls access to the shared resourcemutex): Protects the read_count variableThe elegance of this solution lies in its asymmetry: readers pay a small synchronization cost (protecting the counter), while writers pay the full cost of exclusive access.
The 'first reader locks, last reader unlocks' pattern appears throughout systems programming. It's how reader-writer locks work internally, how reference counting manages shared resources, and how connection pools track active connections. Mastering this pattern pays dividends across many domains.
Let's develop the complete Readers Preference solution, with detailed annotations explaining every aspect of the synchronization logic.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
/* * First Readers-Writers Problem: Readers Preference Solution * * Invariants: * - Multiple readers can access the resource simultaneously * - A writer has exclusive access (no other readers or writers) * - No reader waits unless a writer is currently writing * * Shared Variables: * - read_count: Number of readers currently in critical section * - mutex: Protects read_count (binary semaphore) * - rw_mutex: Ensures mutual exclusion between readers/writers */ #include <semaphore.h> // Shared stateint read_count = 0; // Number of active readerssemaphore mutex = 1; // Protects read_countsemaphore rw_mutex = 1; // Controls access to resource /* * READER PROTOCOL * * Entry: First reader acquires rw_mutex to block writers * Exit: Last reader releases rw_mutex to allow writers */void reader(int id) { while (true) { // ========== ENTRY SECTION ========== wait(mutex); // Step 1: Lock read_count read_count++; // Step 2: Increment reader count if (read_count == 1) { // Step 3: First reader? wait(rw_mutex); // Step 4: Block writers } signal(mutex); // Step 5: Unlock read_count // ========== CRITICAL SECTION ========== read_data(id); // Actually read the resource // ========== EXIT SECTION ========== wait(mutex); // Step 6: Lock read_count read_count--; // Step 7: Decrement reader count if (read_count == 0) { // Step 8: Last reader? signal(rw_mutex); // Step 9: Allow writers } signal(mutex); // Step 10: Unlock read_count // ========== REMAINDER SECTION ========== process_data(id); // Use the data read }} /* * WRITER PROTOCOL * * Simple exclusive access: wait for rw_mutex, write, release */void writer(int id) { while (true) { // ========== ENTRY SECTION ========== wait(rw_mutex); // Wait until no readers or writers // ========== CRITICAL SECTION ========== write_data(id); // Exclusively modify resource // ========== EXIT SECTION ========== signal(rw_mutex); // Release exclusive access // ========== REMAINDER SECTION ========== prepare_next_write(id); // Prepare next update }}Understanding the Reader Protocol:
The reader protocol is more complex than the writer protocol because readers must coordinate with each other to determine first/last status.
Entry Section (Steps 1-5):
mutex to safely access read_countread_count to register this readerrw_mutex to block writers. This step might block if a writer is active.mutex so other readers can proceedExit Section (Steps 6-10):
6. Acquire mutex to safely access read_count
7. Decrement read_count to deregister this reader
8. Check if this was the last reader
9. If last, release rw_mutex to allow waiting writers
10. Release mutex
Critical Observation: Between steps 5 and 6, multiple readers can be in the critical section simultaneously. The mutex only protects the read_count variable, not the actual reading.
Let's trace through several execution scenarios to build intuition for how the solution behaves.
Scenario 1: Single Reader
| Step | Reader R1 Action | read_count | mutex | rw_mutex |
|---|---|---|---|---|
| Initial | — | 0 | 1 | 1 |
| 1 | wait(mutex) succeeds | 0 | 0 | 1 |
| 2 | read_count++ | 1 | 0 | 1 |
| 3 | read_count == 1? YES | 1 | 0 | 1 |
| 4 | wait(rw_mutex) succeeds | 1 | 0 | 0 |
| 5 | signal(mutex) | 1 | 1 | 0 |
| — | ** READING ** | 1 | 1 | 0 |
| 6 | wait(mutex) succeeds | 1 | 0 | 0 |
| 7 | read_count-- | 0 | 0 | 0 |
| 8 | read_count == 0? YES | 0 | 0 | 0 |
| 9 | signal(rw_mutex) | 0 | 0 | 1 |
| 10 | signal(mutex) | 0 | 1 | 1 |
The single reader successfully acquires rw_mutex, reads, and releases it.
Scenario 2: Multiple Concurrent Readers
Now consider two readers arriving nearly simultaneously:
| Step | R1 Action | R2 Action | read_count | mutex | rw_mutex |
|---|---|---|---|---|---|
| Initial | — | — | 0 | 1 | 1 |
| 1 | wait(mutex) ✓ | — | 0 | 0 | 1 |
| 2 | read_count++ | — | 1 | 0 | 1 |
| 3-4 | wait(rw_mutex) ✓ | — | 1 | 0 | 0 |
| 5 | signal(mutex) | — | 1 | 1 | 0 |
| 6 | ** READING ** | wait(mutex) ✓ | 1 | 0 | 0 |
| 7 | ** READING ** | read_count++ | 2 | 0 | 0 |
| 8 | ** READING ** | read_count==1? NO | 2 | 0 | 0 |
| 9 | ** READING ** | signal(mutex) | 2 | 1 | 0 |
| 10 | ** READING ** | ** READING ** | 2 | 1 | 0 |
Key observation: R2 does NOT call wait(rw_mutex) because read_count is already 1 when R2 checks. R2 immediately proceeds to read. This is the concurrency benefit—only the first reader waits for exclusive access.
Scenario 3: Reader-Writer Interaction
What happens when a writer arrives while readers are active?
| Step | R1 Action | W1 Action | read_count | mutex | rw_mutex |
|---|---|---|---|---|---|
| 1-5 | Enters critical section | — | 1 | 1 | 0 |
| 6 | ** READING ** | wait(rw_mutex) BLOCKS | 1 | 1 | 0 |
| 7-9 | Exits, signals rw_mutex | — | 0 | 1 | 1 |
| 10 | — | wait(rw_mutex) succeeds! | 0 | 1 | 0 |
| 11 | — | ** WRITING ** | 0 | 1 | 0 |
The writer blocks at wait(rw_mutex) because R1 holds it. Only when R1 exits and signals rw_mutex does the writer proceed.
Critical Insight: While W1 is blocked waiting for rw_mutex, new readers can still enter! R2 arriving during step 6 would see read_count > 0, skip the wait(rw_mutex), and proceed to read. W1 continues to wait. This is how writer starvation occurs.
Between when a reader acquires rw_mutex and the last reader releases it, ANY number of additional readers can enter without waiting for the blocked writer. A continuous stream of readers keeps rw_mutex locked forever, and the writer starves.
We now rigorously prove that the Readers Preference solution satisfies the safety requirements.
Theorem 1: Reader-Reader Compatibility Statement: Multiple readers can be in the critical section simultaneously.
Proof:
rw_mutex when read_count == 1 (just becoming 1)rw_mutex, subsequent readers find read_count ≥ 1wait(rw_mutex) and proceed directlymutex semaphore only serializes access to read_count, not to the resource itselfsignal(mutex) in entry and wait(mutex) in exit are all in the critical sectionTheorem 2: Writer Exclusivity Statement: When a writer is in the critical section, no other thread is present.
Proof:
wait(rw_mutex) and not yet called signal(rw_mutex)rw_mutex == 0wait(rw_mutex), which blocks on rw_mutex == 0wait(rw_mutex), which blocks on rw_mutex == 0Theorem 3: Reader-Writer Exclusivity Statement: Readers and writers are never in the critical section simultaneously.
Proof:
read_count ≥ 1 and rw_mutex == 0 (held by first reader)wait(rw_mutex), but rw_mutex == 0, so writer blocksrw_mutex == 0 (held by writer)wait(rw_mutex), but rw_mutex == 0, so reader blocksTheorem 4: No Deadlock Statement: The solution is deadlock-free.
Proof by examination of wait-for graph:
mutex is always released before entering the critical section and re-acquired only in the exit sectionmutex while waiting on rw_mutexmutex → release → (potentially) rw_mutex → releaserw_mutex holders never wait for mutex holders who are waiting for rw_mutexThe mutex semaphore is critical for correctness but subtle in its purpose. It does NOT provide mutual exclusion for the resource—it provides mutual exclusion for the read_count variable. Without it, two readers incrementing simultaneously could both think they're the first reader, leading to both calling wait(rw_mutex) with catastrophic results.
The Readers Preference solution has a critical liveness flaw: writers can starve indefinitely. Let's analyze this precisely.
The Starvation Mechanism:
Consider a continuous stream of readers where each new reader arrives before all previous readers have finished:
Time: 0 1 2 3 4 5 6 7 8 ... |----|----|----|----|----|----|----|----|----| ... R1: [==========] Reader 1R2: [==========] Reader 2R3: [==========] Reader 3R4: [==========] Reader 4... read_count: 1 2 2 2 2 2 2 2 2 ... ↑ ↑ ↓↑ ↓↑ ↓↑ ↓↑ ↓↑ ↓↑ (never reaches 0!) W1: [BLOCKED on rw_mutex------------------------→ Writer STARVINGIn this scenario:
rw_mutex, read_count = 1read_count = 1, skips wait(rw_mutex), read_count = 2wait(rw_mutex), BLOCKS (rw_mutex held by readers)read_count = 1, NOT zero, so rw_mutex not signaledread_count = 1, proceeds immediatelyread_count oscillates but never reaches 0rw_mutex, starves indefinitelyFormal Starvation Condition:
A writer starves iff: after calling wait(rw_mutex), there exists an infinite execution trace where read_count never becomes 0.
This condition is satisfied when: arrival_rate(readers) > departure_rate(readers) during any period when a writer is waiting.
| Workload Pattern | Starvation Risk | Reason |
|---|---|---|
| Low reader rate | Low | Gaps allow writers through |
| Bursty readers with gaps | Low | Writers proceed during gaps |
| Steady moderate readers | Medium | Depends on timing |
| High sustained reader rate | High | read_count rarely reaches 0 |
| Readers with long hold times | Very High | Overlapping readers maintain lock |
Writer starvation isn't just theoretical. Database systems using reader-preference locking have experienced cases where background maintenance (writes) never completed because read queries continuously arrived. The solution: switch to a writers-preference or fair policy for those operations.
Understanding the performance characteristics of the Readers Preference solution helps determine when it's appropriate.
Overhead Analysis:
| Operation | Semaphore Ops | Potential Blocking | Contention Point |
|---|---|---|---|
| Reader Entry | 2-3 waits, 1-2 signals | On mutex (brief), On rw_mutex (first only) | mutex |
| Reader Exit | 1 wait, 1-2 signals | On mutex (brief) | mutex |
| Writer Entry | 1 wait | On rw_mutex (potentially long) | rw_mutex |
| Writer Exit | 1 signal | None | None |
Contention Analysis:
The mutex semaphore is a potential contention point because ALL readers must acquire it during entry and exit. With N concurrent readers:
mutex for O(1) operations (increment/decrement and test)However, the actual reading happens in parallel, so if read time >> mutex time, this overhead is negligible.
Throughput Modeling:
Let:
μ_enter = mutex overhead for entryμ_exit = mutex overhead for exitR = actual read timeN = number of concurrent readersEffective throughput: N / (R + μ_enter + μ_exit)
For large R, throughput approaches N/R, the theoretical maximum for a reader-writer workload.
Writer Latency:
Writer latency is highly variable:
The basic Readers Preference solution can be enhanced in several ways to address specific requirements.
Variation 1: Bounded Reader Count
Limit the maximum number of concurrent readers to prevent resource exhaustion:
1234567891011121314151617181920212223242526
#define MAX_READERS 100 semaphore reader_slots = MAX_READERS; // Counting semaphoresemaphore mutex = 1;semaphore rw_mutex = 1;int read_count = 0; void reader() { wait(reader_slots); // Acquire a reader slot (may block) wait(mutex); read_count++; if (read_count == 1) wait(rw_mutex); signal(mutex); read_data(); wait(mutex); read_count--; if (read_count == 0) signal(rw_mutex); signal(mutex); signal(reader_slots); // Release the reader slot}This variation prevents too many readers from consuming all system resources (memory, file handles, etc.) while still allowing up to MAX_READERS concurrent accesses.
Variation 2: Reader Timeout
Avoid indefinite waiting when acquiring read access:
123456789101112131415161718192021222324252627282930
#include <semaphore.h>#include <time.h>#include <errno.h> int try_read_with_timeout(int timeout_ms) { struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); ts.tv_sec += timeout_ms / 1000; ts.tv_nsec += (timeout_ms % 1000) * 1000000; // Try to acquire mutex with timeout if (sem_timedwait(&mutex, &ts) != 0) { if (errno == ETIMEDOUT) return -1; // Timeout acquiring mutex return -2; // Other error } read_count++; if (read_count == 1) { // First reader: try to acquire rw_mutex with remaining timeout if (sem_timedwait(&rw_mutex, &ts) != 0) { read_count--; sem_post(&mutex); return -1; // Timeout waiting for writer to finish } } sem_post(&mutex); return 0; // Success}Variation 3: Statistics Tracking
Production systems often need visibility into lock contention:
1234567891011121314151617181920212223242526272829
// Instrumented reader-writer locktypedef struct { semaphore mutex; semaphore rw_mutex; int read_count; // Statistics atomic_uint64_t total_reads; atomic_uint64_t total_writes; atomic_uint64_t reader_wait_time_ns; atomic_uint64_t writer_wait_time_ns; atomic_uint32_t current_readers; atomic_uint32_t waiting_writers;} instrumented_rwlock_t; void reader_entry(instrumented_rwlock_t* lock) { uint64_t start = get_time_ns(); wait(lock->mutex); lock->read_count++; lock->current_readers++; if (lock->read_count == 1) wait(lock->rw_mutex); signal(lock->mutex); atomic_fetch_add(&lock->reader_wait_time_ns, get_time_ns() - start);}Adding instrumentation to reader-writer locks is essential for diagnosing production issues. Track: wait times, current reader/writer counts, starvation indicators (writers waiting while readers continuously process), and throughput metrics. This data drives informed decisions about lock policy.
The Readers Preference solution is appropriate in specific scenarios. Understanding these scenarios prevents misapplication.
Ideal Use Cases:
| Application Type | Suitable? | Reason |
|---|---|---|
| Analytics dashboard | ✅ Yes | Reads dominate; occasional refresh acceptable |
| Configuration cache | ✅ Yes | Config rarely changes; reads constant |
| DNS resolver cache | ✅ Yes | Lookups vastly outnumber updates |
| Stock trading system | ❌ No | Stale prices cause costly errors |
| Chat application | ❌ No | Message writes must be timely |
| Real-time leaderboard | ⚠️ Maybe | Depends on update frequency requirements |
| Session store | ✅ Yes | Sessions mostly read; writes infrequent |
Do not use Readers Preference for: real-time systems where write latency matters; workloads with sustained high read rates; applications where writer starvation causes correctness issues; systems requiring bounded writer latency. In these cases, consider Writers Preference or Fair solutions.
Proper testing of reader-writer implementations requires systematic approaches that expose concurrency bugs.
Unit Tests for Correctness:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
#include <pthread.h>#include <assert.h>#include <stdatomic.h> // Shared state for testingatomic_int current_readers = 0;atomic_int current_writers = 0;atomic_int max_concurrent_readers = 0;atomic_bool safety_violation = false; void* test_reader(void* arg) { for (int i = 0; i < 1000; i++) { reader_entry(); // Check invariants int readers = atomic_fetch_add(¤t_readers, 1) + 1; int writers = atomic_load(¤t_writers); if (writers > 0) { safety_violation = true; // Reader overlapped with writer! } // Track max concurrency int max = atomic_load(&max_concurrent_readers); while (readers > max) { atomic_compare_exchange_weak(&max_concurrent_readers, &max, readers); } // Simulate work usleep(100 + rand() % 100); atomic_fetch_sub(¤t_readers, 1); reader_exit(); } return NULL;} void* test_writer(void* arg) { for (int i = 0; i < 100; i++) { writer_entry(); int writers = atomic_fetch_add(¤t_writers, 1) + 1; int readers = atomic_load(¤t_readers); if (readers > 0 || writers > 1) { safety_violation = true; // Violated exclusivity! } usleep(50); atomic_fetch_sub(¤t_writers, 1); writer_exit(); } return NULL;} void test_safety() { pthread_t readers[10], writers[2]; for (int i = 0; i < 10; i++) pthread_create(&readers[i], NULL, test_reader, NULL); for (int i = 0; i < 2; i++) pthread_create(&writers[i], NULL, test_writer, NULL); for (int i = 0; i < 10; i++) pthread_join(readers[i], NULL); for (int i = 0; i < 2; i++) pthread_join(writers[i], NULL); assert(!safety_violation && "Safety property violated!"); printf("Max concurrent readers: %d", max_concurrent_readers); printf("Test passed - safety properties hold");}Starvation Detection:
To detect potential starvation, track writer wait times:
12345678910111213141516171819202122232425262728
void test_starvation() { atomic_bool writer_completed = false; // Start continuous readers for (int i = 0; i < 20; i++) { pthread_create(&readers[i], NULL, continuous_reader, NULL); } // Start a writer after brief delay usleep(1000); uint64_t writer_start = get_time_ns(); pthread_t writer; pthread_create(&writer, NULL, single_write, &writer_completed); // Check if writer completes within reasonable time usleep(5000000); // 5 seconds if (!writer_completed) { printf("WARNING: Writer starvation detected!"); printf("Writer waiting for: %.2f seconds", (get_time_ns() - writer_start) / 1e9); } // Signal readers to stop stop_readers = true;}Concurrency bugs often only manifest under specific timing conditions. Use stress testing with many threads, vary timing with random sleeps, run tests under CPU contention (other processes), and use tools like ThreadSanitizer (TSan) to detect data races.
We have thoroughly examined the Readers Preference (First Readers-Writers Problem) solution. Let's consolidate the key concepts:
mutex protects the reader count; rw_mutex provides reader-writer exclusion.What's Next:
The Readers Preference solution's writer starvation problem motivates the Writers Preference (Second Readers-Writers Problem) solution. The next page develops this alternative, which guarantees writers won't starve but at the cost of potentially starving readers. We'll see how to implement this priority inversion and analyze when it's the right choice.
You now have complete mastery of the Readers Preference solution: its implementation, correctness proofs, starvation characteristics, and appropriate use cases. This foundation prepares you for understanding alternative solutions that make different priority tradeoffs.