skip to content

Locks, Conditions, and Events

threading's coordination kit — Lock, RLock, Semaphore, Condition, Event, Barrier — and when each is right. Interviewers ask for a bounded buffer or a one-shot signal to see if you avoid a sleep loop.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

What is the difference between threading.Lock and threading.RLock in Python?

level: juniorimportance: must knowfreq 70%

answer

  1. Re-entering code you already guard
  2. Does the lock know who holds it?
  3. A counter, not a boolean
  4. Balance every acquire with a release

basics

~20 s

threading.Lock is not reentrant: a thread that already holds it blocks forever if it acquires again. threading.RLock records an owner thread and a recursion count, so the same thread may re-enter and must release once per acquire.

solid answer

~50 s

`threading.Lock` is a plain binary mutex with no notion of an owner. It is either locked or unlocked, and a second `acquire()` blocks whoever calls it — including the thread that is already holding it, which is a self-deadlock. `threading.RLock` is a *recursive* mutex: it remembers which thread owns it and how many times that thread has acquired it, so nested `with rlock:` blocks and mutually recursive guarded methods work. The count must go back to zero before another thread gets in, so every `acquire()` needs its own `release()`. Only the owning thread may release an `RLock`; calling `release()` from another thread raises `RuntimeError`. Prefer `Lock` by default — it is cheaper and it makes accidental re-entrancy fail loudly rather than quietly succeed — and reach for `RLock` when a public method that takes the lock legitimately calls another that also takes it. `threading.Condition()` builds on an `RLock` when you construct it without a lock argument.

code

python · 11 lines
python
import threading

lock = threading.Lock()
rlock = threading.RLock()

with rlock:
    with rlock:
        print("same thread re-entered the RLock")

with lock:
    print("second acquire of the same Lock:", lock.acquire(blocking=False))

go deeper

for a junior

Be ready to state the one-line difference — Lock is not reentrant, RLock is — and to say what happens concretely when a thread re-acquires a Lock it already holds: it blocks forever, silently. Know that both work with a with block.

for a middle

Explain the mechanics: RLock stores an owner and a recursion count, needs one release per acquire, and raises RuntimeError if a non-owner releases it. Be able to show the recursive-method case that motivates it and the wrapper-plus-helper refactor that avoids it.

for a senior

Show judgement about when re-entrancy is a design smell rather than a requirement, and how you diagnose a self-deadlock in a live process — threads parked in acquire with no exception and no log. Be ready to justify defaulting to Lock.

for a principal

Own the locking convention for a codebase: which objects are guarded by which lock, whether public methods may call one another under the lock, and whether you standardise on non-reentrant locks to keep that call graph honest. Say what the free-threaded build changes about the stakes.

Both objects are mutual-exclusion primitives from the `threading` module, and both support the context-manager protocol, so the idiomatic use of either is `with the_lock:` rather than a hand-written `acquire()`/`release()` pair. The difference is entirely about **ownership and re-entry**. ### threading.Lock — a binary flag with no owner A `threading.Lock` has exactly two states. `acquire()` returns `True` when it flips the lock from unlocked to locked; if the lock is already locked, the calling thread blocks until someone releases it. There is no record of *who* locked it. Two consequences follow directly: * **It is not reentrant.** If the thread that holds the lock calls `acquire()` again, it blocks waiting for itself, and nothing will ever release it. That is a self-deadlock, and it presents in production as one thread parked forever with no exception and no log line. * **Any thread may release it.** Because the lock has no owner, a `release()` from an unrelated thread is accepted. Releasing a lock that is *not* held raises `RuntimeError`, but releasing one held by a different thread does not. `acquire()` takes two useful arguments. `acquire(blocking=False)` returns `False` immediately instead of waiting, which is the safe way to *test* a lock — note that `locked()` is only a snapshot and is racy the moment it returns. `acquire(timeout=0.5)` waits at most that long and returns `False` if it gives up. Both are how you avoid an unbounded park in code that must stay responsive. ### threading.RLock — an owner plus a counter A `threading.RLock` stores the identity of the owning thread and a recursion depth. When the owner acquires it again, the depth increments and the call returns immediately. When a *different* thread acquires it, that thread blocks exactly as it would on a `Lock`. `release()` decrements the depth; the lock only becomes available to other threads when the depth reaches zero. Only the owner may release it — a release from a non-owning thread raises `RuntimeError`. The classic reason to want one is a class whose public methods each guard themselves and sometimes call one another: ```python import threading class Ledger: def __init__(self): self._lock = threading.RLock() self._entries = [] def add(self, entry): with self._lock: self._entries.append(entry) def add_batch(self, entries): with self._lock: for entry in entries: self.add(entry) # re-enters the same lock ``` With a plain `Lock`, `add_batch` would hang on the first inner `add`. The usual alternative is to split each guarded public method into a thin locking wrapper plus an unlocked `_add_locked` helper that assumes the lock is already held — more code, but it documents the locking discipline explicitly and keeps the cheaper primitive. ### Which to reach for Default to `Lock`. It is the simpler object, it is slightly faster, and its self-deadlock on re-entry is a *feature* during development: it surfaces a call graph you did not know re-entered a critical section. Reach for `RLock` when re-entry is genuinely part of the design and restructuring the call graph would be worse. Treat a codebase that uses `RLock` everywhere as a smell — it usually means nobody knows which functions hold what. Two details interviewers like to probe. First, an `RLock` acquired twice needs two releases; a single `release()` in a `finally` after two acquires leaves the lock held forever, which is the mirror image of the self-deadlock. Second, `threading.Condition()` constructed with no arguments creates an `RLock` internally, which is why `with cond:` nests safely. Finally, none of this changes because CPython has a global interpreter lock. The GIL serializes bytecode execution; it does not make a multi-step read-modify-write atomic, because a thread switch can land between bytecodes. On the free-threaded build — officially supported as of Python 3.14 — there is not even that incidental serialization, so explicit locks are the only thing standing between you and interleaved updates.

  • What happens if a thread calls release() on a threading.RLock it does not own?
    It raises `RuntimeError`. An `RLock` records its owner, so only the acquiring thread can decrement the recursion count. A plain `threading.Lock` behaves differently: it has no owner, so another thread's `release()` is accepted — only releasing a lock that is entirely unheld raises `RuntimeError`.
  • Which lock does threading.Condition() use when you construct it with no arguments?
    An `RLock`. That is why `with cond:` can be nested inside another block that already holds the same condition, and why `wait()` can correctly release and later re-acquire it. You can pass your own lock — including a plain `Lock` — as the constructor argument when you want to share one lock between a condition and other guarded code.
  • How do you avoid blocking forever when acquiring a threading.Lock?
    Pass `blocking=False` for an immediate `True`/`False` answer, or `timeout=<seconds>` to wait a bounded time and get `False` if it gives up. Neither raises. Use the return value — a `with lock:` block always waits indefinitely, so bounded acquisition has to be written as an explicit `acquire()` plus `try`/`finally`.

A Lock is a toilet-door bolt: the door does not care who slid it, and if you forget you already bolted it you stand outside forever. An RLock is a hotel keycard tied to your name that counts how many times you swiped it in.

saying these in an interview costs you the question

  • Says RLock lets several threads hold the lock at once
  • Thinks a second Lock.acquire raises instead of blocking
  • Releases an RLock once after acquiring it twice
  • Uses RLock everywhere as a safer default
  • Claims another thread can release an RLock it does not own
  • Believes the GIL removes the need for either lock

context

open as a page

Why must a threading.Condition wait() be wrapped in a loop that rechecks the predicate?

level: middleimportance: must knowfreq 50%

basics

~20 s

Returning from threading.Condition.wait() only means the waiter reacquired the condition's lock — not that the state it wanted still holds. A notified thread can lose the race to another, and a timeout also returns, so the predicate must be rechecked in a while loop.

open as a page

What does threading.Event give you over a plain boolean flag, and when is clear() unsafe?

level: middleimportance: should knowfreq 45%

basics

~20 s

threading.Event is a flag other threads can block on: wait() sleeps until set() flips it, with no polling. It is level-triggered and wakes every waiter. clear() is unsafe for repeated handshakes, because a set/clear pair can be missed entirely by a thread that never got to run.

open as a page

A threading.Semaphore caps concurrent calls in a fraud-scoring service, but permits leak until throughput reaches zero. What causes that, and how do you prevent it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Some path acquires a permit and never releases it — typically an exception swallowed by a broad except between acquire() and release(). The counter only falls, so concurrency drops until every caller blocks. Acquire with with sem: or try/finally, and use BoundedSemaphore to catch the mirror bug.

open as a page