Read-Write Lock: Concurrent Readers, Exclusive Writers

Standard mutexes are blunt instruments. When you lock a mutex to read shared data, you block every other thread—even those that only want to read. This is wasteful. Reading doesn't modify state, so...

Key Insights

  • Read-write locks allow unlimited concurrent readers but require exclusive access for writers, making them ideal for read-heavy workloads where standard mutexes waste throughput by serializing non-conflicting operations.
  • Writer starvation is a real concern—without careful implementation, a steady stream of readers can indefinitely block writers, so understanding your lock’s fairness guarantees matters.
  • RWLocks aren’t universally faster than mutexes; for short critical sections or write-heavy workloads, the overhead of tracking reader counts can actually hurt performance.

The Reader-Writer Problem

Standard mutexes are blunt instruments. When you lock a mutex to read shared data, you block every other thread—even those that only want to read. This is wasteful. Reading doesn’t modify state, so multiple readers accessing the same data simultaneously is perfectly safe.

Consider a configuration store that gets read thousands of times per second but updated once every few minutes. With a standard mutex, every read operation serializes behind every other read. Your 32-core server effectively becomes single-threaded for configuration access.

This problem appears everywhere: in-memory caches, routing tables, DNS resolvers, database connection pools, and any system where reads vastly outnumber writes. The read-write lock exists specifically to solve this asymmetry.

How Read-Write Locks Work

A read-write lock supports two distinct modes:

Shared (read) mode: Multiple threads can hold the lock simultaneously. They’re promising not to modify the protected data.

Exclusive (write) mode: Only one thread can hold the lock, and no readers can be active. The writer has complete isolation.

The acquisition rules are straightforward:

  • A reader can acquire the lock if no writer holds it and no writer is waiting (in fair implementations)
  • A writer can acquire the lock only when no readers or writers hold it
// Conceptual interface for a read-write lock
trait RWLock<T> {
    // Acquire shared access, returns a read guard
    fn read(&self) -> ReadGuard<T>;
    
    // Acquire exclusive access, returns a write guard
    fn write(&self) -> WriteGuard<T>;
    
    // Try variants that don't block
    fn try_read(&self) -> Option<ReadGuard<T>>;
    fn try_write(&self) -> Option<WriteGuard<T>>;
}

The critical design question is what happens when a writer arrives while readers hold the lock. Does the writer wait for current readers to finish, or do new readers also get blocked? This decision determines whether your lock favors readers or writers—and whether writers can starve.

Implementation Patterns

Building a read-write lock requires tracking reader count and coordinating between readers and writers. Here’s a simplified implementation showing the core mechanics:

#include <mutex>
#include <condition_variable>

class SimpleRWLock {
private:
    std::mutex mtx;
    std::condition_variable cv;
    int readers = 0;
    bool writer_active = false;
    int writers_waiting = 0;

public:
    void read_lock() {
        std::unique_lock<std::mutex> lock(mtx);
        // Writer-preference: block if writers are waiting
        cv.wait(lock, [this] { 
            return !writer_active && writers_waiting == 0; 
        });
        ++readers;
    }

    void read_unlock() {
        std::unique_lock<std::mutex> lock(mtx);
        --readers;
        if (readers == 0) {
            cv.notify_all();
        }
    }

    void write_lock() {
        std::unique_lock<std::mutex> lock(mtx);
        ++writers_waiting;
        cv.wait(lock, [this] { 
            return !writer_active && readers == 0; 
        });
        --writers_waiting;
        writer_active = true;
    }

    void write_unlock() {
        std::unique_lock<std::mutex> lock(mtx);
        writer_active = false;
        cv.notify_all();
    }
};

This implementation uses writer-preference: when a writer is waiting, new readers block. This prevents writer starvation but can temporarily reduce read throughput during contention.

Reader-preference implementations let readers proceed as long as no writer currently holds the lock. This maximizes read throughput but risks starving writers indefinitely.

Fair queuing implementations maintain a strict FIFO order. Requests are granted in arrival order, regardless of type. This provides the strongest fairness guarantees but reduces potential parallelism.

Standard Library Implementations

Every major systems language provides production-quality read-write locks. Here’s how to use them for a thread-safe cache:

package main

import (
    "sync"
    "time"
)

type Cache struct {
    mu    sync.RWMutex
    items map[string]CacheEntry
}

type CacheEntry struct {
    Value     interface{}
    ExpiresAt time.Time
}

func NewCache() *Cache {
    return &Cache{
        items: make(map[string]CacheEntry),
    }
}

// Get acquires a read lock - multiple goroutines can read simultaneously
func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    
    entry, exists := c.items[key]
    if !exists || time.Now().After(entry.ExpiresAt) {
        return nil, false
    }
    return entry.Value, true
}

// Set acquires a write lock - exclusive access required
func (c *Cache) Set(key string, value interface{}, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    
    c.items[key] = CacheEntry{
        Value:     value,
        ExpiresAt: time.Now().Add(ttl),
    }
}

// Size demonstrates read lock for aggregate operations
func (c *Cache) Size() int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return len(c.items)
}

Go’s sync.RWMutex is writer-preferring to prevent starvation. Rust’s std::sync::RwLock provides similar semantics with compile-time safety guarantees. C++17’s std::shared_mutex works with std::shared_lock for readers and std::unique_lock for writers.

Performance Characteristics

Read-write locks aren’t magic. They introduce overhead that standard mutexes don’t have: tracking reader counts requires atomic operations, and coordinating between readers and writers needs additional synchronization.

package main

import (
    "sync"
    "testing"
)

type MutexCounter struct {
    mu    sync.Mutex
    value int
}

func (c *MutexCounter) Get() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

type RWCounter struct {
    mu    sync.RWMutex
    value int
}

func (c *RWCounter) Get() int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.value
}

// Benchmark with 95% reads, 5% writes
func BenchmarkMutexReadHeavy(b *testing.B) {
    counter := &MutexCounter{}
    b.RunParallel(func(pb *testing.PB) {
        i := 0
        for pb.Next() {
            if i%20 == 0 { // 5% writes
                counter.mu.Lock()
                counter.value++
                counter.mu.Unlock()
            } else {
                counter.Get()
            }
            i++
        }
    })
}

func BenchmarkRWLockReadHeavy(b *testing.B) {
    counter := &RWCounter{}
    b.RunParallel(func(pb *testing.PB) {
        i := 0
        for pb.Next() {
            if i%20 == 0 { // 5% writes
                counter.mu.Lock()
                counter.value++
                counter.mu.Unlock()
            } else {
                counter.Get()
            }
            i++
        }
    })
}

In read-heavy scenarios (95%+ reads) with longer critical sections, RWLocks typically show 2-10x throughput improvements. But for short critical sections or write-heavy workloads, the overhead of managing reader counts can make RWLocks slower than simple mutexes.

Use RWLocks when:

  • Reads significantly outnumber writes (10:1 or higher)
  • Critical sections are non-trivial (more than a few memory accesses)
  • You have high thread counts competing for access

Stick with mutexes when:

  • Write ratio exceeds 20-30%
  • Critical sections are tiny
  • Simplicity matters more than peak throughput

Common Pitfalls and Best Practices

The most dangerous RWLock mistake is attempting to upgrade a read lock to a write lock:

use std::sync::RwLock;
use std::thread;

fn main() {
    let lock = RwLock::new(0);
    
    // Thread 1: Holds read lock, wants to upgrade
    let handle1 = thread::spawn({
        let lock = &lock;
        move || {
            let _read_guard = lock.read().unwrap();
            // DANGER: Still holding read lock, trying to get write lock
            // This will deadlock because we're waiting for ourselves
            let _write_guard = lock.write().unwrap(); // DEADLOCK!
        }
    });
    
    // This pattern ALWAYS deadlocks
}

The upgrade attempt deadlocks because the write lock waits for all readers to release, but you’re one of those readers. The solution is to release the read lock before acquiring the write lock—accepting that the data might change between operations.

// Safe pattern: release read lock, then acquire write lock
fn safe_read_then_write(lock: &RwLock<i32>) {
    let value = {
        let guard = lock.read().unwrap();
        *guard // Copy the value
    }; // Read lock released here
    
    // Data might have changed! Check again if needed
    let mut guard = lock.write().unwrap();
    *guard = value + 1;
}

Other pitfalls to avoid:

  • Long hold times: Holding any lock while doing I/O, network calls, or heavy computation destroys concurrency
  • Reentrancy assumptions: Most RWLocks aren’t reentrant; acquiring a read lock twice from the same thread may deadlock
  • Ignoring fairness: Know whether your implementation is reader-preferring, writer-preferring, or fair

Alternatives and When to Use Them

Read-write locks aren’t the only solution to the reader-writer problem:

Copy-on-write (COW): Writers create a complete copy of the data structure, modify the copy, then atomically swap a pointer. Readers never block. Best for small, infrequently-modified data.

Read-Copy-Update (RCU): A Linux kernel technique where readers access data without any synchronization. Writers update data and wait for all pre-existing readers to finish before reclaiming old versions. Excellent for read-mostly kernel data structures.

Lock-free structures: Concurrent data structures using atomic operations instead of locks. Higher complexity but can eliminate blocking entirely.

Decision framework:

  1. Write ratio > 30%? Use a mutex.
  2. Need maximum read throughput with rare writes? Consider COW or RCU.
  3. Can tolerate some blocking with moderate write frequency? RWLock is your tool.
  4. Building infrastructure code with extreme performance requirements? Evaluate lock-free alternatives.

Read-write locks occupy a sweet spot: they’re simpler than lock-free programming while offering real concurrency benefits for read-heavy workloads. Understand their tradeoffs, benchmark your specific use case, and choose accordingly.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.