user@techdebt:~/blog$_
$ cd ..

One Writer, Many Readers

R1 idle R2 idle R3 idle W1 idle Shared Data { records } FREE readers: 0 writers: 0 wait-R: 0 wait-W: 0 BLOCKED BLOCKED BLOCKED BLOCKED
policy:
$ rwlock inspector
Step through to inspect state
$ simulation.log

the pattern

A regular threading.Lock() forces one thread at a time. That’s fine when every operation modifies shared state. But what about a config cache that hundreds of threads read and one thread updates every few minutes? With a plain lock, every reader blocks every other reader. That’s a bottleneck for no reason. Readers don’t conflict with each other. Only writes need exclusion.

A read-write lock captures this insight:

  • Read lock (shared). Multiple threads can hold it at once.
  • Write lock (exclusive). Only one thread can hold it. No readers and no other writers allowed.
import threading

class ConfigCache:
    def __init__(self):
        self.config = {"model": "v1", "timeout": 30}
        self.rwlock = RWLock()

    def read_config(self, key):
        with self.rwlock.read_lock():
            return self.config.get(key)

    def update_config(self, key, value):
        with self.rwlock.write_lock():
            self.config[key] = value

Ten threads calling read_config run in parallel. When one thread calls update_config, it waits for current readers to finish, then gets exclusive access.

building a read-write lock

Python’s standard library doesn’t include a read-write lock. Here’s one built with threading.Condition:

import threading
from contextlib import contextmanager

class RWLock:
    def __init__(self):
        self._cond = threading.Condition(threading.Lock())
        self._readers = 0
        self._writer = False

    @contextmanager
    def read_lock(self):
        with self._cond:
            while self._writer:
                self._cond.wait()
            self._readers += 1
        try:
            yield
        finally:
            with self._cond:
                self._readers -= 1
                if self._readers == 0:
                    self._cond.notify_all()

    @contextmanager
    def write_lock(self):
        with self._cond:
            while self._writer or self._readers > 0:
                self._cond.wait()
            self._writer = True
        try:
            yield
        finally:
            with self._cond:
                self._writer = False
                self._cond.notify_all()

The writer waits until there are zero readers and no other writer. Readers wait only if a writer is active.

the fairness problem

The lock above has a subtle bug. Not a correctness bug. A starvation bug.

Imagine a steady stream of readers. Reader 1 holds the lock. Reader 2 arrives and also gets in. Before Reader 1 finishes, Reader 3 arrives. The reader count never hits zero. A waiting writer never gets its turn.

Three fairness policies:

Reader preference. That’s what we built above. Writers can starve if readers are continuous.

Writer preference. When a writer is waiting, new readers queue behind it. Prevents writer starvation but can starve readers.

class WriterPreferRWLock:
    def __init__(self):
        self._cond = threading.Condition(threading.Lock())
        self._readers = 0
        self._writer = False
        self._writers_waiting = 0

    @contextmanager
    def read_lock(self):
        with self._cond:
            while self._writer or self._writers_waiting > 0:
                self._cond.wait()
            self._readers += 1
        try:
            yield
        finally:
            with self._cond:
                self._readers -= 1
                if self._readers == 0:
                    self._cond.notify_all()

    @contextmanager
    def write_lock(self):
        with self._cond:
            self._writers_waiting += 1
            while self._writer or self._readers > 0:
                self._cond.wait()
            self._writers_waiting -= 1
            self._writer = True
        try:
            yield
        finally:
            with self._cond:
                self._writer = False
                self._cond.notify_all()

FIFO (fair). Threads get access in the order they requested it. No starvation, but less concurrency. Readers arriving after a waiting writer must wait even though they could safely run concurrently.

Try the different fairness modes in the simulation above.