Skip to content

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

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.

Five tasks contending for one second, 1 ms held A grid of 4 rows by 5 columns. Five tasks contending for one second, 1 ms held primitive re-acquire without yielding acquisitions per task wait p50 / p99 / max order Lock no 188-189 4.2 / 4.3 / 4.4 ms 0,1,2,3,4,0,1... Lock yes 188-189 4.3 / 4.4 / 4.5 ms 0,1,2,3,4,0,1... Semaphore(2) no 374-375 1.1 / 2.2 / 2.2 ms 0,1,2,3,4,0,1... Semaphore(2) yes 374-375 1.1 / 2.2 / 2.2 ms 0,1,2,3,4,0,1... Python 3.14.

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.

Release, wake, and handoff A sequence of 5 messages between 5 participants. Release, wake, and handoff holder lock waiter 1 other runnable tasks waiter 2 release() wake first waiter take their turns first runs: owns the lock if waiter 1 was cancelled: wake waiter 2 The lock is idle from release until the woken task actually runs.

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.

Lock acquisitions per second as other tasks crowd the loop 3 horizontal bars comparing 0 other tasks, idle p50 < 0.01 ms with the others. Lock acquisitions per second as other tasks crowd the loop 0 other tasks, idle p50 < 0.01 ms 945/s 100 other tasks, idle p50 0.17 ms 629/s 1,000 other tasks, idle p50 1.68 ms 149/s Five lock users, 1 ms held; each other task does ~5 us of work per turn. Every handoff and every await inside the lock waits for the loop.

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.

Is the lock's behaviour the problem? A decision on What looks wrong under contention with 4 outcomes. Is the lock's behaviour the problem? What looks wrong under contention? one task gets the lock repeatedly not with asyncio.Lock FIFO, no barging lock throughput drops under load shorter critical sections, less lag 945/s to 149/s one tenant crowds out others per-tenant gate in front rotation by tenant waits grow without bound timeout on acquire bound the wait Order is guaranteed; latency is not.

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.