Skip to content

Implementing an Async Read-Write Lock

A service keeps a routing table in memory. Hundreds of request handlers read it concurrently, each read spanning an await — they look up a route, then await a health check against it — and every thirty seconds a refresher rebuilds the table, which takes a few awaited calls. Protecting it with one asyncio.Lock is correct but serialises every reader behind every other reader, turning the table into a bottleneck. Not protecting it lets a handler see half a rebuilt table. The textbook answer is a read-write lock: any number of readers may hold it at once, a writer holds it alone. asyncio does not ship one, and the naive version has a nasty property — under steady read traffic, a writer never gets in. This guide builds a read-write lock on asyncio.Condition, measures reader-preference starvation, adds writer preference to fix it, makes waiting cancellation-safe, and describes the cases where a simpler design is better.

Prerequisites

Who may enter while someone holds the lock? A grid of 3 rows by 2 columns. Who may enter while someone holds the lock? currently held by new reader new writer nobody enters enters one or more readers enters (unless writer waits) waits a writer waits waits The parenthesis is writer preference: a waiting writer closes the door to new readers.

1. Decide whether you need one

An event loop runs one coroutine at a time, so state is only at risk when a critical section contains an await. If readers do not await while using the shared data, they need no lock at all; and if writers can build a new structure and swap a reference, readers never see partial state.

import asyncio


class RoutingTable:
    """Copy-on-write: readers take a snapshot reference; writers swap in a new dict."""

    def __init__(self) -> None:
        self._routes: dict[str, str] = {}

    def snapshot(self) -> dict[str, str]:
        return self._routes                             # one reference read: atomic on the loop

    async def refresh(self, fetch) -> None:
        new_routes = await fetch()                      # build fully before publishing
        self._routes = new_routes                       # single assignment: no partial state


async def handle(table: RoutingTable, path: str) -> str:
    routes = table.snapshot()                           # consistent for the whole request
    await asyncio.sleep(0.01)                           # awaits no longer matter
    return routes.get(path, "404")


async def main() -> None:
    table = RoutingTable()

    async def fetch() -> dict[str, str]:
        await asyncio.sleep(0.005)
        return {"/orders": "orders-v2", "/users": "users-v1"}

    await table.refresh(fetch)
    print(await asyncio.gather(handle(table, "/orders"), table.refresh(fetch), handle(table, "/users")))


asyncio.run(main())

Copy-on-write needs no lock and has no starvation, at the cost of rebuilding the structure on each write. Choose a read-write lock when writers must modify shared state in place across awaits — incrementally updating a large index, or coordinating a multi-step mutation with an external system — and readers must not observe the intermediate steps.

Verify: both handlers return their routes while a refresh runs concurrently, with no lock anywhere.

2. Build a reader-preference RWLock on a Condition

The state is two numbers — active readers and whether a writer is active — protected by one condition. Readers wait until no writer is active; writers wait until nobody holds the lock at all. Context managers keep acquire and release paired.

import asyncio
import contextlib


class RWLock:
    def __init__(self, writer_preference: bool = True) -> None:
        self._cond = asyncio.Condition()
        self._readers = 0
        self._writer = False
        self._waiting_writers = 0
        self._prefer_writers = writer_preference

    def _can_read(self) -> bool:
        if self._writer:
            return False
        return not (self._prefer_writers and self._waiting_writers)   # step 3

    @contextlib.asynccontextmanager
    async def read(self):
        async with self._cond:
            await self._cond.wait_for(self._can_read)
            self._readers += 1
        try:
            yield
        finally:
            async with self._cond:
                self._readers -= 1
                if self._readers == 0:
                    self._cond.notify_all()            # a writer may now proceed

    @contextlib.asynccontextmanager
    async def write(self):
        async with self._cond:
            self._waiting_writers += 1
            try:
                await self._cond.wait_for(lambda: not self._writer and self._readers == 0)
            except BaseException:
                self._waiting_writers -= 1
                self._cond.notify_all()                # readers blocked on our intent may go
                raise
            self._waiting_writers -= 1
            self._writer = True
        try:
            yield
        finally:
            async with self._cond:
                self._writer = False
                self._cond.notify_all()                # readers and writers re-check

Note that readers release the condition's internal lock before doing their work — the yield sits outside async with self._cond: — which is what lets many readers be inside at once. The condition's lock protects only the counters.

Verify: ten concurrent readers each holding the lock across an await all report being inside at the same time.

3. Measure writer starvation, then prefer writers

With reader preference, a new reader may enter whenever no writer is active. Under continuous read traffic there is always at least one reader inside, so a waiting writer never sees readers == 0. Writer preference closes the door to new readers as soon as a writer is waiting.

import asyncio
import time


async def starvation_test(writer_preference: bool) -> str:
    lock = RWLock(writer_preference=writer_preference)
    stop = False
    peak_readers = 0

    async def reader() -> None:
        nonlocal peak_readers
        while not stop:
            async with lock.read():
                peak_readers = max(peak_readers, lock._readers)
                await asyncio.sleep(0.005)             # reads overlap continuously

    readers = [asyncio.create_task(reader()) for _ in range(10)]
    await asyncio.sleep(0.022)
    started = time.perf_counter()
    try:
        async with asyncio.timeout(0.5):
            async with lock.write():
                waited = f"writer waited {(time.perf_counter() - started) * 1000:.1f} ms"
    except TimeoutError:
        waited = "writer starved for 500 ms"
    stop = True
    await asyncio.gather(*readers)
    return f"peak readers {peak_readers}, {waited}"


async def main() -> None:
    print("reader preference:", await starvation_test(writer_preference=False))
    print("writer preference:", await starvation_test(writer_preference=True))


asyncio.run(main())

With ten overlapping readers, reader preference starved the writer for the full 500 ms deadline, while writer preference let it in after a few milliseconds — the time for readers already inside to finish — and both configurations still reached ten concurrent readers. The cost of writer preference is symmetric: a steady stream of writers can starve readers. For refresh-style workloads, where writes are rare, that is the right trade.

Verify: the reader-preference run reports starvation, and the writer-preference run reports a wait of a few milliseconds.

Reader preference starves the writer 6 lanes over time. Reader preference starves the writer readers r1 r2 holding readers r3 r4 holding readers r5 r6 holding readers r7 r8 holding writer, reader pref. waiting forever writer, writer pref. waiting writes time → Measured: 500 ms of starvation versus a few milliseconds with writer preference.

4. Keep cancellation from wedging the lock

Two paths can leave the lock in a bad state if cancellation lands at the wrong moment. A writer cancelled while waiting must withdraw its intent — otherwise, with writer preference, readers wait forever for a writer that left. And a holder cancelled while inside must still release, which the context managers guarantee.

import asyncio


async def main() -> None:
    lock = RWLock(writer_preference=True)
    reader_entered = asyncio.Event()

    async def long_reader() -> None:
        async with lock.read():
            reader_entered.set()
            await asyncio.sleep(0.05)

    async def impatient_writer() -> None:
        async with lock.write():
            pass

    first = asyncio.create_task(long_reader())
    await reader_entered.wait()
    writer = asyncio.create_task(impatient_writer())
    await asyncio.sleep(0.01)
    writer.cancel()                                     # gives up while waiting
    await asyncio.gather(writer, return_exceptions=True)

    async with asyncio.timeout(1):                      # a new reader must not be blocked
        async with lock.read():
            print("new reader got in; waiting writers:", lock._waiting_writers)
    await first


asyncio.run(main())

Without the except BaseException branch in write(), _waiting_writers would stay at one after the cancellation and the final reader would hang until the timeout. With it, the counter is restored, waiting readers are notified, and the new reader gets in immediately.

Verify: the script prints new reader got in; waiting writers: 0 without hitting the one-second timeout; deleting the except branch makes it time out.

5. Use it with a timeout and without upgrades

Two usage rules prevent the classic read-write lock deadlocks. Never try to "upgrade" by taking the write lock while holding the read lock: the writer waits for readers to reach zero, including itself. And bound waits in request paths so a stuck holder produces an error rather than a pile-up.

import asyncio


class RouteIndex:
    def __init__(self) -> None:
        self._lock = RWLock()
        self._routes: dict[str, str] = {}

    async def lookup(self, path: str, timeout: float = 0.5) -> str | None:
        async with asyncio.timeout(timeout):
            async with self._lock.read():
                await asyncio.sleep(0)                  # e.g. validate the route asynchronously
                return self._routes.get(path)

    async def upsert_if_missing(self, path: str, target: str) -> bool:
        # Do NOT: async with read(): ... async with write(): ...  -> self-deadlock
        async with self._lock.write():                  # take the stronger lock once
            if path in self._routes:
                return False
            await asyncio.sleep(0)                      # e.g. register with a service mesh
            self._routes[path] = target
            return True


async def main() -> None:
    index = RouteIndex()
    results = await asyncio.gather(*(index.upsert_if_missing("/orders", f"orders-{i}") for i in range(5)))
    print("inserted once:", results.count(True) == 1, "| lookup:", await index.lookup("/orders"))


asyncio.run(main())

"Check then act" belongs entirely under the write lock: checking under a read lock and then acquiring the write lock leaves a gap in which another writer can act first. The concurrency test above runs five writers for the same key and exactly one inserts.

Verify: inserted once: True, and the lookup returns the single inserted target.

How should this shared state be protected? A decision on Is there an await inside the critical section with 3 outcomes. How should this shared state be protected? Is there an await inside the critical section? no await in between no lock needed loop never interleaves yes, can rebuild state copy-on-write swap readers keep a snapshot yes, must mutate in place read-write lock writer preference The lock is the last resort, not the first.

Verification

The read-write lock is correct and useful when:

  • It is needed at all: critical sections contain awaits and in-place mutation, where copy-on-write would not work.
  • Readers overlap: peak concurrent readers under load exceeds one.
  • Writers are not starved: with writer preference, a writer waiting behind continuous readers gets in within the duration of the longest current read.
  • Cancellation cannot wedge it: a writer cancelled while waiting leaves the waiting count at zero and readers proceed.
  • No upgrades and bounded waits: code takes the write lock directly for check-then-act, and request paths use timeouts.

Pitfalls & edge cases

  • Upgrading from read to write. Holding a read lock while waiting for the write lock deadlocks against yourself. Release first or take the write lock from the start.
  • Long reads under writer preference. A single slow reader delays the writer and, behind it, every new reader. Keep awaits inside read sections short, or snapshot data and release.
  • Threads. This lock is for coroutines on one loop. Threads need threading primitives, and mixing the two needs a bridge as in how to safely share state between async tasks and threads.
  • Notify storms. notify_all() on every release wakes all waiters to re-check their predicates; with thousands of waiters this costs CPU. Separate conditions for readers and writers reduce it if profiling shows a problem.
  • Recursion. The lock is not re-entrant; a reader calling a function that takes the read lock again works, but a writer calling one that takes the write lock deadlocks.

Frequently Asked Questions

Does asyncio have a read-write lock?

No. The standard library provides Lock, Semaphore, Event, Condition and Barrier but no reader-writer lock. You can build one on asyncio.Condition by tracking active readers, an active writer and waiting writers, or avoid the need with copy-on-write, where writers build a new structure and swap a single reference.

Why is my writer starved by an async read-write lock?

With reader preference, new readers may enter whenever no writer is active, so under continuous overlapping reads the reader count never reaches zero and a waiting writer never proceeds. Writer preference fixes this by making new readers wait whenever a writer is waiting.

Do I need locks for shared state in asyncio at all?

Only when a critical section contains an await. Coroutines on one event loop cannot be interrupted between awaits, so reading or updating state without awaiting in between is safe. Once an await sits between a read and a dependent write, other tasks can run in the gap and a lock or copy-on-write design is needed.

Can I upgrade a read lock to a write lock?

Not safely. A task holding the read lock that waits for the write lock waits for the reader count to reach zero, which includes itself, so it deadlocks. Take the write lock directly for operations that check and then modify state, or release the read lock and re-check the state after acquiring the write lock.