Loading learning content...
Having understood the readers-writers problem—where naive mutual exclusion serializes operations that could safely proceed in parallel—we now introduce the Read-Write Lock (also called Readers-Writer Lock or Shared-Exclusive Lock). This synchronization primitive elegantly captures the asymmetry between read and write operations, enabling concurrent reads while maintaining exclusive write access.
The read-write lock is one of the most important concurrency primitives in systems programming. It appears in virtually every major programming language's standard library, in database engines, operating system kernels, and distributed systems. Understanding how it works internally—not just how to use it—transforms you from a consumer of concurrency primitives into someone who can reason about, debug, and optimize concurrent systems at a fundamental level.
By the end of this page, you will understand how read-write locks work internally, how to implement one from basic primitives, the correct usage patterns and idioms, and the subtle correctness properties that distinguish a working lock from a broken one. You'll be equipped to use read-write locks confidently and debug them when things go wrong.
A read-write lock provides two types of access to a protected resource:
Shared (Read) Access: Multiple threads can hold read locks simultaneously. Each reader "shares" the lock with other readers.
Exclusive (Write) Access: Only one thread can hold a write lock, and no readers can hold locks simultaneously. The writer has "exclusive" access to the resource.
These two modes directly map to the operation compatibility we established earlier: reads are compatible with reads (shared), while writes are compatible with nothing (exclusive).
| Current Lock State | Read Lock Request | Write Lock Request |
|---|---|---|
| Unlocked | ✅ Granted | ✅ Granted |
| Read Locked (1 reader) | ✅ Granted | ⏳ Blocked |
| Read Locked (N readers) | ✅ Granted | ⏳ Blocked |
| Write Locked | ⏳ Blocked | ⏳ Blocked |
A typical read-write lock exposes four fundamental operations:
1234567891011121314151617181920212223242526
interface ReadWriteLock { /** * Acquires the read (shared) lock. * Blocks if a writer holds the lock or is waiting. * Multiple readers may hold the lock concurrently. */ readLock(): void; /** * Releases the read lock. * Must be called once for each successful readLock(). */ readUnlock(): void; /** * Acquires the write (exclusive) lock. * Blocks until all readers have released and no other writer holds the lock. */ writeLock(): void; /** * Releases the write lock. * Must be called once for each successful writeLock(). */ writeUnlock(): void;}Lock acquisition and release must be paired correctly, even when exceptions occur. Always use try-finally blocks (or language constructs like Python's with statement or Go's defer) to ensure locks are released. A lock that isn't released will cause deadlock.
Understanding the internal mechanics of a read-write lock deepens your intuition about its behavior and helps you reason about performance characteristics. Let's build a read-write lock from first principles using only a mutex and condition variables.
A read-write lock tracks the following state:
Here's a full implementation of a read-write lock with writer preference (we'll discuss preference policies later):
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
class ReadWriteLock { private readers: number = 0; private writer: boolean = false; private waitingWriters: number = 0; private mutex: Mutex = new Mutex(); private readCondition: ConditionVariable = new ConditionVariable(); private writeCondition: ConditionVariable = new ConditionVariable(); async readLock(): Promise<void> { await this.mutex.acquire(); try { // Wait if a writer is active OR writers are waiting // (The second condition prevents writer starvation) while (this.writer || this.waitingWriters > 0) { await this.readCondition.wait(this.mutex); } // Increment reader count - we now hold the read lock this.readers++; } finally { this.mutex.release(); } } async readUnlock(): Promise<void> { await this.mutex.acquire(); try { this.readers--; // If this was the last reader and writers are waiting, // wake up one writer if (this.readers === 0 && this.waitingWriters > 0) { this.writeCondition.signal(); } } finally { this.mutex.release(); } } async writeLock(): Promise<void> { await this.mutex.acquire(); try { // Indicate we're waiting this.waitingWriters++; // Wait until no readers and no writer while (this.readers > 0 || this.writer) { await this.writeCondition.wait(this.mutex); } // We're no longer waiting - we have the lock this.waitingWriters--; this.writer = true; } finally { this.mutex.release(); } } async writeUnlock(): Promise<void> { await this.mutex.acquire(); try { this.writer = false; // Wake up all waiting readers OR one waiting writer if (this.waitingWriters > 0) { // Give preference to writers this.writeCondition.signal(); } else { // Wake up all readers this.readCondition.broadcast(); } } finally { this.mutex.release(); } }}We use separate condition variables for readers and writers because they have different wait conditions. Readers wait for 'no writer active', while writers wait for 'no readers and no writer active'. Using separate conditions allows us to wake up only the appropriate waiting threads, reducing unnecessary context switches.
Let's trace through several scenarios to solidify understanding of how the read-write lock operates.
Initial State: readers=0, writer=false, waitingWriters=0 T1 calls readLock(): - Acquires mutex - Check: writer(false) || waitingWriters(0) → false, no wait needed - readers = 1 - Releases mutex State: readers=1, writer=false, waitingWriters=0 T2 calls readLock() (while T1 still reading): - Acquires mutex - Check: writer(false) || waitingWriters(0) → false, no wait needed - readers = 2 - Releases mutex State: readers=2, writer=false, waitingWriters=0 T3 calls readLock() (while T1 and T2 still reading): - Same process, readers becomes 3 State: readers=3, writer=false, waitingWriters=0 Result: All three readers execute concurrently ✓State: readers=2, writer=false, waitingWriters=0 T4 (writer) calls writeLock(): - Acquires mutex - waitingWriters = 1 - Check: readers(2) > 0 || writer(false) → true, MUST WAIT - Releases mutex, waits on writeCondition State: readers=2, writer=false, waitingWriters=1 T1 calls readUnlock(): - Acquires mutex - readers = 1 - Check: readers(1) == 0 && waitingWriters(1) > 0 → false - No signal sent (still readers active) - Releases mutex State: readers=1, writer=false, waitingWriters=1 T2 calls readUnlock(): - Acquires mutex - readers = 0 - Check: readers(0) == 0 && waitingWriters(1) > 0 → TRUE - Signals writeCondition, waking T4 - Releases mutex T4 (writer) wakes up: - Re-acquires mutex - Re-check: readers(0) > 0 || writer(false) → false, proceed! - waitingWriters = 0 - writer = true - Releases mutex State: readers=0, writer=true, waitingWriters=0Writer T4 has exclusive access ✓State: readers=1, writer=false, waitingWriters=1 (T1 reading, T4 waiting to write) T5 calls readLock(): - Acquires mutex - Check: writer(false) || waitingWriters(1) > 0 → TRUE (waiting writers!) - Releases mutex, waits on readCondition State: readers=1, writer=false, waitingWriters=1, T5 waiting This is writer preference in action! Even though no writer is currently active, T5 must wait because a writer is queued.This prevents writer starvation. After T1 finishes and T4 gets the write lock:State: readers=0, writer=true, waitingWriters=0, T5 still waiting After T4 finishes (writeUnlock): - writer = false - waitingWriters(0) > 0? NO - So: broadcast readCondition, waking T5 T5 wakes up: - Re-check: writer(false) || waitingWriters(0) → false - readers = 1 - T5 now holds read lock ✓Notice that threads always re-check their wait condition after waking up (the while loops, not if statements). This handles spurious wakeups and ensures correctness even when multiple threads wake up simultaneously. Never use if to check conditions before waiting—always use while.
Now let's see how to apply read-write locks in real-world scenarios. We'll examine common patterns that maximize the benefits of concurrent reads.
The most common use case—protecting an in-memory cache that is read frequently and updated occasionally:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
import { ReadWriteLock } from './rwlock'; interface CacheEntry<T> { value: T; expiresAt: number;} class ThreadSafeCache<K, V> { private cache: Map<K, CacheEntry<V>> = new Map(); private rwLock: ReadWriteLock = new ReadWriteLock(); private defaultTtlMs: number; constructor(defaultTtlMs: number = 60000) { this.defaultTtlMs = defaultTtlMs; } async get(key: K): Promise<V | undefined> { await this.rwLock.readLock(); try { const entry = this.cache.get(key); if (!entry) { return undefined; } // Check expiration if (Date.now() > entry.expiresAt) { // Entry expired - need to delete, but we only have read lock! // We'll return undefined and let a write operation clean up return undefined; } return entry.value; } finally { await this.rwLock.readUnlock(); } } async set(key: K, value: V, ttlMs?: number): Promise<void> { await this.rwLock.writeLock(); try { this.cache.set(key, { value, expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs), }); } finally { await this.rwLock.writeUnlock(); } } async delete(key: K): Promise<boolean> { await this.rwLock.writeLock(); try { return this.cache.delete(key); } finally { await this.rwLock.writeUnlock(); } } async getOrCompute( key: K, compute: () => Promise<V>, ttlMs?: number ): Promise<V> { // First, try to read const cached = await this.get(key); if (cached !== undefined) { return cached; } // Cache miss - compute and store // Note: This has a race condition (multiple threads might compute) // For production, consider using a more sophisticated pattern const value = await compute(); await this.set(key, value, ttlMs); return value; } async cleanup(): Promise<number> { await this.rwLock.writeLock(); try { const now = Date.now(); let removed = 0; for (const [key, entry] of this.cache) { if (now > entry.expiresAt) { this.cache.delete(key); removed++; } } return removed; } finally { await this.rwLock.writeUnlock(); } }}A configuration service with bulk refresh capabilities:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
class ConfigurationService { private config: Map<string, ConfigValue> = new Map(); private rwLock: ReadWriteLock = new ReadWriteLock(); private version: number = 0; // Fast path: read single value async getValue(key: string): Promise<ConfigValue | undefined> { await this.rwLock.readLock(); try { return this.config.get(key); } finally { await this.rwLock.readUnlock(); } } // Read multiple values atomically (same snapshot) async getValues(keys: string[]): Promise<Map<string, ConfigValue>> { await this.rwLock.readLock(); try { const result = new Map<string, ConfigValue>(); for (const key of keys) { const value = this.config.get(key); if (value !== undefined) { result.set(key, value); } } return result; } finally { await this.rwLock.readUnlock(); } } // Bulk refresh - replaces entire configuration async refresh(newConfig: Map<string, ConfigValue>): Promise<void> { await this.rwLock.writeLock(); try { this.config.clear(); for (const [key, value] of newConfig) { this.config.set(key, value); } this.version++; console.log(`Configuration refreshed to version ${this.version}`); } finally { await this.rwLock.writeUnlock(); } } // Update single value async setValue(key: string, value: ConfigValue): Promise<void> { await this.rwLock.writeLock(); try { this.config.set(key, value); this.version++; } finally { await this.rwLock.writeUnlock(); } } // Version check (lock-free if using atomic) async getVersion(): Promise<number> { await this.rwLock.readLock(); try { return this.version; } finally { await this.rwLock.readUnlock(); } }}Using read-write locks to protect a graph data structure that supports concurrent traversals:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
class ConcurrentGraph<T> { private adjacencyList: Map<T, Set<T>> = new Map(); private rwLock: ReadWriteLock = new ReadWriteLock(); // Traversal operations use read locks async hasPath(from: T, to: T): Promise<boolean> { await this.rwLock.readLock(); try { // BFS to find path const visited = new Set<T>(); const queue: T[] = [from]; while (queue.length > 0) { const current = queue.shift()!; if (current === to) { return true; } if (visited.has(current)) { continue; } visited.add(current); const neighbors = this.adjacencyList.get(current); if (neighbors) { for (const neighbor of neighbors) { if (!visited.has(neighbor)) { queue.push(neighbor); } } } } return false; } finally { await this.rwLock.readUnlock(); } } // Multiple concurrent traversals are safe async getNeighbors(node: T): Promise<Set<T>> { await this.rwLock.readLock(); try { return new Set(this.adjacencyList.get(node) ?? []); } finally { await this.rwLock.readUnlock(); } } // Mutations require exclusive access async addEdge(from: T, to: T): Promise<void> { await this.rwLock.writeLock(); try { if (!this.adjacencyList.has(from)) { this.adjacencyList.set(from, new Set()); } this.adjacencyList.get(from)!.add(to); } finally { await this.rwLock.writeUnlock(); } } async removeEdge(from: T, to: T): Promise<boolean> { await this.rwLock.writeLock(); try { const neighbors = this.adjacencyList.get(from); return neighbors?.delete(to) ?? false; } finally { await this.rwLock.writeUnlock(); } }}A correct read-write lock implementation must satisfy certain formal properties. Understanding these helps you verify implementations and debug issues.
writer == true, no other thread can set it to true until it becomes false.writer == true), no readers can hold it (readers == 0). Conversely, if readers hold the lock (readers > 0), no writer can hold it (writer == false).12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Invariant verification helper (for testing/debugging)class VerifiedReadWriteLock extends ReadWriteLock { private assertInvariants(): void { // Invariant 1: Writer exclusion if (this.writer) { console.assert( this.readers === 0, "VIOLATION: Writer active with readers present" ); } // Invariant 2: Reader count is non-negative console.assert( this.readers >= 0, "VIOLATION: Negative reader count" ); // Invariant 3: waitingWriters is non-negative console.assert( this.waitingWriters >= 0, "VIOLATION: Negative waiting writer count" ); // These assertions should NEVER fire in a correct implementation } async readLock(): Promise<void> { await super.readLock(); this.assertInvariants(); } async readUnlock(): Promise<void> { await super.readUnlock(); this.assertInvariants(); } async writeLock(): Promise<void> { await super.writeLock(); this.assertInvariants(); } async writeUnlock(): Promise<void> { await super.writeUnlock(); this.assertInvariants(); }}The most common bugs in read-write lock implementations are: (1) Not re-checking conditions after waking from wait (using if instead of while), (2) Forgetting to decrement waitingWriters when a writer acquires the lock, (3) Signaling the wrong condition variable, and (4) Not handling interruption correctly during wait.
Most programming languages provide production-quality read-write lock implementations. Here's how to use them effectively:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
import java.util.concurrent.locks.ReentrantReadWriteLock;import java.util.concurrent.locks.ReadWriteLock; public class JavaRWLockExample { private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Map<String, Object> data = new HashMap<>(); // Fair option prevents starvation // private final ReadWriteLock rwLock = new ReentrantReadWriteLock(true); public Object read(String key) { rwLock.readLock().lock(); try { return data.get(key); } finally { rwLock.readLock().unlock(); } } public void write(String key, Object value) { rwLock.writeLock().lock(); try { data.put(key, value); } finally { rwLock.writeLock().unlock(); } } // Lock downgrade: convert write lock to read lock public Object readAfterWrite(String key, Object defaultValue) { rwLock.writeLock().lock(); try { Object value = data.get(key); if (value == null) { data.put(key, defaultValue); value = defaultValue; } // Downgrade: acquire read lock while holding write lock rwLock.readLock().lock(); // Then release write lock rwLock.writeLock().unlock(); // Continue with just read lock return value; } finally { rwLock.readLock().unlock(); } }}In production code, always use your platform's standard read-write lock implementation. They are heavily tested, optimized for your platform's memory model, and handle edge cases you might miss. Implement your own only for learning or when you need very specific behavior.
We've covered the read-write lock solution in depth—from concept to implementation to usage patterns. Let's consolidate the key points:
while loops, not if statements, to handle spurious wakeups correctly.ReentrantReadWriteLock (Java), sync.RWMutex (Go), ReaderWriterLockSlim (C#), or equivalent for your platform.What's Next:
The read-write lock we've explored has a subtle but important property: it gives preference to writers over new readers. This prevents writer starvation but can cause reader starvation. The next page explores the fairness dimension of read-write locks: reader preference versus writer preference, and strategies for achieving true fairness.
You now understand how read-write locks work internally and how to use them correctly. You can implement a basic read-write lock from primitives and understand the invariants a correct implementation must maintain. Next, we'll explore the fairness tradeoffs that distinguish different read-write lock designs.