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¶
- Python 3.11+, standard library only.
- Condition variables from coordinating producers and consumers with asyncio.Condition.
- Lock semantics from Synchronization Primitives and deadlock rules from avoiding deadlocks with nested asyncio locks.
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.
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.
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
threadingprimitives, 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.
Related¶
- Synchronization Primitives — up to the topic overview for asyncio locks and coordination.
- Coordinating producers and consumers with asyncio.Condition — the primitive this lock is built on.
- Asyncio Fundamentals & Event Loop Architecture — the section overview for tasks and shared state.