Locks, and the Four Ways They Go Wrong
A lock is two methods and a rule. Almost every problem with one is a question of which lock, what it guards, how long it is held, and in what order.
A lock is two methods and a rule. Almost every problem with one is a question of which lock, what it guards, how long it is held, and in what order.
This explorer needs JavaScript: every interleaving, cost and speed-up on it is computed in the page rather than downloaded as an image.
Four things in that output are the whole API. A lock is either held or not. blocking=False and timeout= turn "wait forever" into a question you can answer, which is the only way to write code that reports contention instead of hanging on it. And releasing an unheld lock is an error rather than a no-op, which is worth knowing because it is how a try/finally written slightly wrong announces itself.
A threading.Lock has two operations. acquire() takes it, waiting if another thread holds it; release() hands it back. Everything else is a consequence, and all of it can be watched from one thread:
fresh lock, locked? False
after acquire: True
acquire again, non-blocking: False <- False means another acquire would have waited
acquire again, with a timeout: False <- gave up rather than hanging
after release: False
releasing twice: RuntimeError: release unlocked lockNote what the second line proves: this single thread just failed to acquire a lock it already holds. That is not a quirk of the sandbox — a Lock has no concept of an owner, so "already held" is all it knows, even when the holder is you.
The editors here run in a browser interpreter with no OS threads, so the protocol above is real and the two-thread examples are shown with the output they produce on CPython.
Every lock should be taken with a context manager:
with lock:
counter += 1rather than:
lock.acquire()
counter += 1 # if this raises, the lock is never released
lock.release()The second form is a deadlock waiting for an exception. Anything that raises between the two calls leaves the lock held forever, and every other thread that wants it blocks permanently — a hang with no traceback and no obvious cause, because the thread that broke the invariant has long since moved on. with releases on the way out however the block exits, including on an exception and on a return.
If you genuinely need the two-call form — acquiring in one function and releasing in another, which is usually a design worth revisiting — then the release belongs in a finally.
A plain Lock is not reentrant: the same thread taking it twice deadlocks against itself. An RLock counts, and the same thread may take it repeatedly as long as it releases the same number of times.
RLock taken twice by one thread: fine
and released twice: fine
plain Lock, same thread again: False <- it would have waited for itself, foreverThe reason this matters is that self-deadlock rarely looks like taking a lock twice. It looks like a locked method calling another locked method on the same object:
class Counter:
def __init__(self):
self._lock = threading.Lock()
self._n = 0
def bump(self):
with self._lock:
self._n += 1
def bump_twice(self):
with self._lock: # held here...
self.bump() # ...and bump() wants it too. Hangs.RLock fixes that instance and is also a warning sign: if your public methods call each other while holding a lock, the lock is doing two jobs. The alternative is a private unlocked _bump that both public methods call inside their own with, which keeps the lock shallow and the ownership obvious.
A lock serialises everything inside it, so the block is the part of your program that cannot go faster with more threads. The cost of the lock itself is measurable:
200,000 increments
no lock 99 ms
lock inside the loop 312 ms (3.1x)
lock outside the loop 89 ms (0.9x)Taking the lock two hundred thousand times triples the cost of the loop. Taking it once is indistinguishable from not taking it at all. The absolute milliseconds depend on the interpreter — this browser build is slower than CPython — but the shape is the point, and the editor prints whatever your runtime measures.
Which pulls in two directions, and the tension is the real design work.
Hold it too briefly and you pay per-acquire overhead, and you may not actually be protecting the invariant — two separate locked blocks are two atomic operations, not one, so anything that must be consistent across both needs one block around both.
Hold it too long and you have serialised your program. A lock held across a network call is the extreme case: every other thread waits for your HTTP request. The rule that follows is to hold a lock over the shortest region that still contains the whole invariant, and never across I/O.
Two locks and two threads that take them in opposite orders is the classic, and it needs nothing exotic:
# thread A # thread B
with lock_accounts: with lock_audit:
with lock_audit: with lock_accounts:
transfer() record()Thread A holds accounts and waits for audit. Thread B holds audit and waits for accounts. Neither will ever release, because releasing is the thing each is waiting to be able to do. The program does not crash; it stops, which is considerably harder to diagnose.
The explorer above this article is that situation: two threads, two locks, and a toggle that makes both take them in the same order. There is no clever fix — a global ordering on locks is the fix. Number them, always acquire in ascending order, and a cycle becomes impossible, because a cycle requires somebody to go backwards.
Where an ordering is impractical, the fallback is a timeout:
if not lock_audit.acquire(timeout=1.0):
lock_accounts.release() # back off, let the other thread win
raise CouldNotLock("audit")That converts a permanent hang into a recoverable error, which is strictly better and still worse than an ordering. It also introduces livelock, where two threads politely back off and retry in lockstep forever; randomising the retry delay is the usual answer.
A lock answers "only one at a time". Three neighbours answer different questions, and reaching for a lock when you wanted one of these is its own category of mistake:
Event is_set? False | wait(0.05) -> False (timed out)
after set: True | wait() -> True (returns at once)
Semaphore(2) three non-blocking acquires: True True False
Semaphore stray releases accepted; permits now 3 <- silent bug
BoundedSemaphore one release too many -> ValueError: Semaphore released too many times
Condition wait(0.05) -> False (nobody notified)
notify_all() with no waiters is a no-op, not an errorEvent is a one-way flag with waiting attached. It is for "has the thing happened yet" — startup finished, shutdown requested — and the right shape for asking a pool of workers to stop: they check stop.is_set() between items, and one set() reaches all of them. Note that wait() returns the flag's state rather than None, so if not ev.wait(timeout): is how you distinguish "it happened" from "I gave up".
Semaphore counts permits instead of excluding. Three connections to a service that tolerates three: Semaphore(3), and the fourth caller waits. The output above shows why BoundedSemaphore is usually the better choice — a plain one silently accepts releases that were never matched by an acquire, so a bug that leaks permits raises the cap instead of raising an error. That is the same argument as a plain Lock erroring on a double release, and the plain Semaphore is the odd one out.
Condition is a lock plus a way to wait for what it protects to change. The pattern is always the same, and the while is not optional:
with cond:
while not predicate():
cond.wait()
# the predicate is true and we hold the lockA wakeup does not promise the predicate is true — another thread may have been woken first and taken the thing — so re-checking in a loop is what makes it correct. In practice a queue.Queue is a Condition that has already been written correctly, and reaching for the queue rather than the primitive is usually the right call.
Manual acquire and release. One exception between them and the lock is held forever. Use with.
A different lock for the same data. Two threads taking two different locks around one variable are not synchronised at all. A lock protects what everyone agrees it protects.
Locking the write but not the read. A reader that takes no lock can observe a half-finished update. Every access needs the lock, including read-only ones that then act on what they saw.
Holding a lock across I/O. Correct and slow enough to look like a hang, and it can turn a slow remote server into a stalled process.
Taking two locks in whatever order reads best. That is how the deadlock above gets written. Pick an order once, write it down, follow it everywhere.
Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.
What does this module say about “The whole protocol”?
What does this module say about “Lock or RLock”?
What does this module say about “How long to hold it”?
Understand deadlocks, livelocks, race conditions and how to avoid them in concurrent code.