Understanding asyncio.Lock Fairness¶
When several tasks want the same asyncio.Lock, two questions decide how a service behaves under contention: who gets it next, and how long the lock sits idle between holders. asyncio answers the first strictly and the second at the mercy of everything else on the loop. Measured on Python 3.14 with five tasks repeatedly taking a lock and holding it for 1 ms, for one second: each task acquired it 188–189 times, in round-robin order 0, 1, 2, 3, 4, 0, 1, …, whether or not tasks yielded between releasing and re-acquiring; a Semaphore(2) behaved the same at 374–375 acquisitions each. A task that released the lock and immediately called acquire() again, with three tasks already waiting, got it back only after all three — no barging. When the waiter at the head of the queue was woken and then cancelled before it ran, the lock passed to the next waiter and was not left locked. The cost of that orderliness is the handoff: a woken waiter runs only when the loop reaches it, and with 1,000 other runnable tasks the lock sat idle for a median of 1.68 ms between holders and throughput fell from 945 to 149 acquisitions per second. This guide explains both halves.
Prerequisites¶
- Python 3.11+.
- Lock basics, from choosing asyncio.Lock vs Semaphore vs Event.
- The topic overview, Synchronization Primitives.
1. Expect strict FIFO handoff¶
asyncio.Lock keeps its waiters in a queue. On release(), it wakes the first waiter by completing that waiter's future; the woken task then runs and owns the lock. Contention therefore produces a predictable rotation:
lock = asyncio.Lock()
async def worker(i, counts, order):
while not stopping():
async with lock:
counts[i] += 1
order.append(i)
await asyncio.sleep(0.001) # 1 ms of work while holding
Measured over one second with five workers: 189, 189, 189, 189 and 188 acquisitions, and the first twelve holders were 0, 1, 2, 3, 4, 0, 1, 2, 3, 4, 0, 1. With Semaphore(2) the rotation was the same and each task got 374–375 acquisitions, two holders at a time. The median wait was 4.2–4.3 ms — four other holders of 1 ms each, plus overhead — and the maximum 4.4 ms. A FIFO queue gives every waiter a wait bounded by the queue length times the hold time, which is the property that makes lock waits predictable.
Verify: under contention, acquisitions are evenly spread across tasks and waits are bounded by queue length × hold time.
2. Know that a releasing task cannot barge back in¶
With some locks, a thread that releases and immediately re-acquires usually wins, because the woken waiter has not run yet. asyncio.Lock prevents this: acquire() on an unlocked lock still waits if other tasks are queued and not cancelled:
async def hog():
for _ in range(5):
await lock.acquire()
lock.release() # no await before the next acquire
Measured with three tasks already waiting when the hog started: the order of acquisition was other0, other1, other2, then the hog five times. The hog's immediate re-acquire queued behind the waiters rather than taking the lock that its own release had just freed. This is what made the "greedy" runs in step 1 come out identical to the polite ones. One consequence for code that batches work under a lock: releasing and re-acquiring in a loop does not let a task keep the lock — it hands it round — so if a sequence must be atomic, hold the lock across all of it.
Verify: a task that releases and re-acquires in a loop does not starve other waiters.
3. Rely on cancellation not losing the lock¶
A waiter can be cancelled — a timeout, a client disconnect — at any point, including after release() has chosen it but before it has run. asyncio handles that case by passing the wake-up to the next waiter:
await lock.acquire()
tasks = [asyncio.create_task(waiter(i)) for i in range(3)]
await asyncio.sleep(0) # all three are queued
lock.release() # wakes waiter 0
tasks[0].cancel() # cancelled before it runs
Measured: waiter 0 recorded its cancellation, waiters 1 and 2 then acquired the lock in order, and at the end lock.locked() was False. Older versions of asyncio had races in exactly this path; on current versions, cancellation at any point leaves the lock consistent. Code that implements its own primitives on top of futures needs the same care, as covered in cancelling futures and their callbacks.
Verify: a test that cancels queued and just-woken waiters ends with the lock free and every surviving waiter served.
4. Account for the handoff waiting on the loop¶
FIFO handoff guarantees the order, not the speed. The woken waiter becomes runnable, and joins the end of the loop's queue of ready callbacks; until it runs, the lock is free but unusable. Every await inside the critical section has the same cost, since the holder must also wait for its turn to continue:
async def spinner(): # stands in for other request handlers
while running():
sum(range(200)) # a few microseconds of work
await asyncio.sleep(0)
Measured with five lock users holding for 1 ms: alone, 945 acquisitions/s and a median idle gap between holders of under 0.01 ms; with 100 runnable spinners, 629/s and 0.17 ms; with 1,000, 149/s and 1.68 ms (p99 2.57 ms). With a busy loop, the lock's effective throughput collapsed because both the handoffs and the holders' own sleep(0.001) stretched by a full pass over the run queue. Keep critical sections short and free of unnecessary awaits; do the I/O before taking the lock and only the state change under it; and treat high loop lag as a lock problem too, measured as in measuring event loop lag in production.
Verify: lock throughput under realistic loop load is measured, and critical sections contain only the awaits they must.
5. Add your own policy when FIFO is not fair enough¶
FIFO is fair to tasks, not to the requests, tenants or priorities behind them. A tenant that opens fifty concurrent requests gets fifty places in the queue; an urgent health check waits behind all of them; a long queue gives every waiter a long wait. When that matters, put the policy in front of the lock:
class TenantFairLock:
"""At most one queued waiter per tenant; the rest wait in their tenant's own lock."""
def __init__(self):
self._lock = asyncio.Lock()
self._per_tenant: dict[str, asyncio.Lock] = {}
@asynccontextmanager
async def hold(self, tenant: str):
gate = self._per_tenant.setdefault(tenant, asyncio.Lock())
async with gate: # a tenant's requests queue among themselves
async with self._lock: # tenants rotate fairly on the shared lock
yield
Each tenant occupies at most one place in the shared queue, so tenants rotate regardless of how many requests each sends. Measured with tenant A holding 50 queued requests of 2 ms each: a single request from tenant B waited 98.3 ms behind them on a plain lock, and 1.1 ms with the per-tenant gate. For deadlines, bound the wait itself rather than relying on the queue being short, as in acquiring locks with timeouts; and for weighted priorities across a shared resource, the scheduling techniques in fair scheduling across tenants in a worker pool apply.
Verify: under load from one heavy tenant, other tenants' lock waits stay bounded by the number of tenants, not the number of requests.
Verification¶
Lock behaviour is understood and accounted for when:
- Waits under contention are bounded by queue length × hold time, as FIFO predicts.
- No code relies on barging to keep a lock across a release.
- Cancellation tests leave the lock free and every surviving waiter served.
- Lock throughput is measured under real loop load, and fairness policies sit in front of the lock where tasks are not the right unit.
Diagnostic Hook: record, per acquisition, the wait time and the queue length at the moment of waiting. Waits that grow with queue length are FIFO working as designed; waits that grow while queue length stays small point at the loop — slow handoffs and stretched critical sections — rather than at contention.
Pitfalls & edge cases¶
- Releasing and re-acquiring to "keep" the lock. Measured: three waiters went first.
- Awaits inside critical sections on a busy loop. Measured: throughput fell to 149/s.
- Assuming FIFO means fair to tenants. A tenant's many requests occupy many places.
- Home-made primitives on futures. Get cancellation of a woken waiter right, as asyncio does.
Frequently Asked Questions¶
Is asyncio.Lock fair?
It hands the lock to waiters in FIFO order: five contending tasks got 188 to 189 acquisitions each in round-robin order in testing, and a task releasing and immediately re-acquiring queued behind existing waiters.
Can a task barge ahead of waiters on an asyncio.Lock?
No: acquire() waits while non-cancelled waiters are queued, even if the lock is momentarily unlocked.
Why is my asyncio lock slow under load?
Each handoff waits for the woken task's turn on the loop. With 1,000 other runnable tasks, lock acquisitions fell from 945/s to 149/s and the lock sat idle a median 1.68 ms between holders.
What happens if a waiter is cancelled just after being woken?
The lock is passed to the next waiter; in testing, the remaining waiters were served and the lock ended unlocked.
Related¶
- Synchronization Primitives — up to the topic overview.
- Catching release bugs with BoundedSemaphore — when the count itself goes wrong.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.