Skip to content

Acquiring Locks with Timeouts

asyncio.Lock.acquire() and Semaphore.acquire() have no timeout parameter; a task that asks for a busy lock waits as long as it takes. Under overload, that means unbounded queues and latency, and the standard fix — wrapping something in asyncio.timeout — has a correct and an incorrect placement. Measured on Python 3.14 with ten tasks contending for a lock to perform a two-step update that takes 30 ms, each allowed 50 ms: with the timeout wrapped around the whole async with lock: block, the timeout fired inside the critical section and left one update half-written; with the timeout around the acquisition only, none was. With 200 requests per second offered to a lock that could serve about 120, waiting without a timeout served all 400 requests at a p99 latency of 1,210 ms, still rising when the run ended; an acquisition timeout of 100 ms served 262, rejected 138 immediately, and held p99 at 108 ms. And across 2,000 trials per version in which the holder released at the same instant the waiter's wait_for deadline expired, no version leaked the lock — but Python 3.10 and 3.11 acquired it every time while 3.12 and 3.14 timed out every time. This guide shows where the timeout goes.

Prerequisites

1. Put the timeout around the acquire, not the block

The obvious code bounds everything inside the timeout — including the work done while holding the lock:

async with asyncio.timeout(0.05):
    async with lock:                         # the deadline covers the wait AND the work
        state["phase"] = "begin"
        await write_part_one()
        await write_part_two()               # cancelled here if the wait used up the budget

Measured with ten contending tasks, a 30 ms two-step update and a 50 ms timeout: one update was interrupted between its two steps — the timeout counted the time spent waiting for the lock, left too little for the work, and cancelled it halfway. Bound only the wait, then do the work without that deadline:

from contextlib import asynccontextmanager

@asynccontextmanager
async def acquire_within(lock, timeout: float):
    async with asyncio.timeout(timeout):
        await lock.acquire()                 # TimeoutError here means we never held it
    try:
        yield
    finally:
        lock.release()

async with acquire_within(lock, 0.05):
    await write_part_one()
    await write_part_two()

Measured: eight tasks timed out while waiting, two completed, and none was left half-written — a timeout could now only happen before the critical section started. If the work itself also needs a deadline, give it a separate one, sized for the work and chosen knowing it may interrupt it, with cleanup that restores consistency, as in making database transactions cancellation-safe.

Verify: a TimeoutError from lock acquisition never leaves shared state between steps.

Ten tasks, a 30 ms two-step update, 50 ms each A grid of 2 rows by 4 columns. Ten tasks, a 30 ms two-step update, 50 ms each timeout placement completed timed out left half-written around async with lock: (wait + work) 1 9 1 around lock.acquire() only 2 8 (all while waiting) 0 Python 3.14.

2. Shed load instead of queueing it

Under sustained overload, waiting without a limit turns a lock into an unbounded queue. Every request is eventually served, late:

async def handle(request):
    try:
        async with acquire_within(store_lock, 0.1):
            return await update_store(request)
    except TimeoutError:
        return Response(status=503, headers={"Retry-After": "1"})

Measured with 400 requests offered at 200 per second to a critical section held for 8 ms — capacity about 120 per second: without a timeout, all 400 were served with a median latency of 621 ms and p99 of 1,210 ms, and the queue was still growing when the arrivals stopped. With a 100 ms acquisition timeout, 262 were served at a p99 of 108 ms and 138 were rejected immediately with an error the client can act on. Which outcome is right depends on the caller, but a request that will be served after its client has given up is pure waste, and a growing queue delays every request behind it. The same reasoning bounds any waiting room, as in capacity planning with Little's law.

Verify: under offered load above capacity, served requests stay within the latency budget and the excess is rejected quickly.

p99 latency of served requests, 200 req/s offered to ~120 req/s of capacity 2 horizontal bars comparing no timeout: 400 served with the others. p99 latency of served requests, 200 req/s offered to ~120 req/s of capacity no timeout: 400 served 1,210 ms 100 ms acquire timeout: 262 served, 138 rejected 108 ms Critical section held 8 ms; 400 requests at 200/s. Rejecting early kept the served requests fast.

3. Rely on the lock not leaking when a timeout races a release

The delicate moment is a timeout that fires just as the lock is handed over: the waiter may be woken and cancelled at the same time. asyncio's lock passes the lock on if a woken waiter is cancelled, so the lock should never be left held by nobody:

async def trial(delay):
    lock = asyncio.Lock()
    await lock.acquire()
    asyncio.get_running_loop().call_later(delay, lock.release)   # release at the deadline
    try:
        await asyncio.wait_for(lock.acquire(), delay)
        lock.release()
        return "acquired"
    except TimeoutError:
        return "leaked" if lock.locked() else "timed out cleanly"

Measured over 2,000 trials on each of Python 3.10, 3.11, 3.12 and 3.14: no trial leaked the lock. The tie-break differed by version — on 3.10 and 3.11 every trial acquired the lock, on 3.12 and 3.14 every trial timed out — because wait_for was reimplemented in 3.12, as described in handling wait_for behaviour changes in Python 3.12. Code must therefore handle both outcomes at the boundary, and tests that depend on which one happens are testing the interpreter, not your code. Custom primitives built on futures need the same "pass it on if cancelled" handling.

Verify: a stress test of timeouts racing releases never ends with the lock held and no owner.

4. Apply the same pattern to semaphores and conditions

Every asyncio primitive that waits takes a timeout the same way — around the waiting call only:

async with asyncio.timeout(0.5):
    await pool_slots.acquire()               # Semaphore
try:
    await use_slot()
finally:
    pool_slots.release()

async with condition:
    async with asyncio.timeout(2):
        await condition.wait_for(lambda: queue_ready)    # re-acquires the lock before returning

For Condition.wait(), the timeout bounds the wait for a notification; when it fires, the condition re-acquires its lock before the TimeoutError propagates, so the async with condition: block still exits normally. For Event.wait() and Queue.get(), wrapping them in asyncio.timeout is all that is needed, since they hold nothing. For distributed locks, where the wait is a network operation, the same rule applies with extra care for a lock acquired just as the client gave up, as covered in Distributed Locks & Coordination.

Verify: every wait on a primitive in request paths has a timeout, and none of those timeouts covers the protected work.

5. Report and tune acquisition timeouts

An acquisition timeout is a capacity signal. Count timeouts per lock, record wait times, and alert when timeouts become routine:

async def acquire_measured(name: str, lock, timeout: float):
    start = time.perf_counter()
    try:
        async with asyncio.timeout(timeout):
            await lock.acquire()
    except TimeoutError:
        LOCK_TIMEOUTS.labels(name).inc()
        raise
    finally:
        LOCK_WAIT.labels(name).observe(time.perf_counter() - start)

Choose the timeout from the caller's latency budget minus the work's own time, not from what seems generous: with an 8 ms critical section and a 200 ms budget, a 100 ms acquisition timeout leaves headroom; a 5 s one only makes failures slow. Timeouts that rise steadily mean the critical section is too long or too popular — shorten it, split the lock per key as in implementing per-key async locks, or add capacity.

Verify: wait times and timeout counts are exported per lock, and the timeout leaves room in the latency budget for the protected work.

Where should the timeout go? A decision on What needs bounding with 4 outcomes. Where should the timeout go? What needs bounding? the wait for the lock timeout around acquire() 0 half-written the work under the lock separate deadline + cleanup may interrupt it overload reject on acquire timeout p99 1,210 to 108 ms timeouts keep rising shorten or split the lock capacity signal Never let a wait deadline cancel the critical section.

Verification

Lock timeouts are placed correctly when:

  • Only the acquisition is inside the timeout, never the protected work.
  • Requests that cannot get the lock in time are rejected quickly, keeping served latency bounded.
  • Timeouts racing releases leave the lock consistent, and tests accept either outcome at the boundary.
  • Wait times and timeout counts are exported per lock.

Diagnostic Hook: compare the distribution of lock wait times with the critical section's own duration. Waits that are a small multiple of the hold time mean modest contention; waits approaching the timeout mean the queue is long, and the fix is the lock's capacity, not a longer timeout.

Pitfalls & edge cases

  • asyncio.timeout around async with lock:. Measured: an update left half-written.
  • No timeout under overload. Measured: p99 1,210 ms and rising.
  • Tests that assume who wins a timeout/release race. 3.11 and 3.12 resolve it differently.
  • Timeouts longer than the caller's own deadline. They only make failures slower.

Frequently Asked Questions

How do I acquire an asyncio.Lock with a timeout?

Wrap only the acquire: async with asyncio.timeout(t): await lock.acquire(), then do the work in try/finally that releases. Timing out the whole async with lock: block interrupted an update halfway in testing.

Should requests wait for a lock indefinitely?

Not under overload: without a timeout, p99 latency reached 1,210 ms and kept rising; a 100 ms acquire timeout held it at 108 ms by rejecting 138 of 400 requests.

Can a timeout leave an asyncio lock locked forever?

In 2,000 trials per version where the release coincided with the deadline, no version leaked it; 3.10 and 3.11 acquired, while 3.12 and 3.14 timed out.

Does Condition.wait() work with asyncio.timeout?

Yes: the condition re-acquires its lock before the TimeoutError propagates, so the surrounding async with block exits normally.