Skip to content
← All articles

Threading Primitives Beyond Lock

Condition, Event, Semaphore, Barrier — which one a problem wants, why wait_for replaces every hand-written wait loop, and why notify() stalls a producer/consumer buffer.

Here is a shape that appears in every codebase that has not met Condition:

while not self.ready:
    time.sleep(0.01)

It works, which is the problem. It also burns a core, adds up to 10 ms of latency to something that could have been microseconds, and — because the author eventually notices the latency and lowers the sleep to 0.001 — burns ten times as much core to shave 9 ms. And it hides a real bug: nothing guarantees self.ready is read consistently with whatever set it.

threading ships six primitives beyond Lock. Knowing which one a problem wants is most of the skill.

The six

Lock — mutual exclusion, no recursion. The one detail people miss: acquire(timeout=...) returns a bool, and ignoring it is how you end up running a critical section without the lock. In 3.13 Lock became a real class rather than a factory function, so isinstance(x, threading.Lock) and subclassing both work.

RLock — the same thread may acquire it repeatedly, releasing once per acquire. Correct when a locked public method calls another locked public method. Frequently a smell: it often means the lock boundary is drawn around methods rather than around invariants. (3.14 added RLock.locked().)

Condition — a lock plus a waiting room. This is the one that replaces the sleep loop, and the only one that handles “wait until this predicate about shared state becomes true”.

Event — a one-shot broadcast flag. set() releases every current and future waiter until clear(). Perfect for “shutdown requested”; wrong for anything with a count, because set() has no memory of how many waiters it released.

Semaphore / BoundedSemaphore — a counter of permits. Use BoundedSemaphore unless you have a reason not to: it raises ValueError on a release that would push the count above the initial value, which catches the double-release that a plain Semaphore silently turns into extra capacity.

Barrier — N threads meet, then all proceed. It has a permanent broken state: if any participant times out or calls abort(), the barrier is broken and every subsequent wait() raises BrokenBarrierError until someone calls reset(). Barriers are for fixed-size phase-synchronised computations, not for request handling.

wait_for, and why never to write the loop yourself

The hand-written form of a condition wait is:

with cond:
    while not predicate():
        cond.wait()

The while is not optional and is the part people drop. wait() can return without the predicate being true — another thread was notified first and took the item, or the platform delivered a spurious wakeup — so a single if gives you a thread that proceeds on a false premise. Turning it into a while with a timeout is fiddlier still: you have to track the deadline yourself, because each wait(timeout) restarts the clock and a loop of them can wait arbitrarily long.

Condition.wait_for(predicate, timeout) is that loop, written correctly, with the deadline arithmetic done for you. It returns the predicate’s last value — so False means “timed out”, and that is the return you must check:

with self._cond:
    if not self._cond.wait_for(lambda: len(self._items) < self._capacity, timeout):
        raise TimeoutError("buffer full")
    self._items.append(item)
    self._cond.notify_all()
💡Both put and get wait on the same Condition. When put appends an item, should it call notify() or notify_all()? click to reveal

notify_all(), and the reason is that one condition is serving two different predicates.

notify() wakes exactly one waiter, chosen arbitrarily. With producers and consumers sharing a condition, the woken thread may be another producer waiting for room — which the append you just did did not create. That producer re-evaluates its predicate, finds it still false, and goes back to waiting. The consumer that could have made progress was never woken. The item sits there and the system stalls, permanently, with no error.

notify_all() wakes everyone; each re-checks its own predicate; the ones that cannot proceed go back to sleep. The waste is a few spurious wakeups, which is nothing. The alternative — two Condition objects sharing one Lock, one for “not full” and one for “not empty”, each notified with notify() — is the efficient version, and worth it only when profiling says the wakeups matter.

The rule: notify() is safe only when every waiter on the condition is waiting for the same thing and any one of them can consume the event.

Coming in 3.15

Python 3.15 adds three helpers for a related problem — sharing an iterator between threads. itertools.serialize_iterator wraps an iterator so that concurrent next() calls are serialised, @itertools.synchronized_iterator decorates a generator function to the same effect, and itertools.concurrent_tee gives each consumer its own independent view. They close a genuine footgun: a bare generator consumed by a thread pool is a read-modify-write on the generator’s frame, and the failure is silently dropped or duplicated items rather than an exception.

💡A worker pool has four threads all calling next(shared_gen) on the same generator, with a Lock around each call. Is that correct? click to reveal

Yes — that is exactly what serialize_iterator does for you, and doing it by hand with a lock is correct as long as every consumer takes the lock.

The failure mode is not the locked next(); it is the for item in shared_gen: that somebody adds later, which calls __next__ directly and bypasses the lock entirely. Wrapping the iterator so the synchronisation lives inside it — rather than in a convention every call site must remember — is what makes the property survive the next contributor. That is the general argument for encapsulating a lock inside the object it protects, which is also why BoundedBuffer owns its Condition instead of taking one as a parameter.