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.