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¶
- Python 3.11+ for
asyncio.timeout. - Lock behaviour under contention, from understanding asyncio.Lock fairness.
- The topic overview, Synchronization Primitives.
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.
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.
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.
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.timeoutaroundasync 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.
Related¶
- Synchronization Primitives — up to the topic overview.
- Catching release bugs with BoundedSemaphore — the release side of the same discipline.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.