A worker thread takes jobs from a list that other threads fill. When the list is empty, the worker has to wait. Your first version might check the list in a loop:
def take_job() -> str:
while True:
with lock:
if jobs:
return jobs.pop(0)
time.sleep(0.01) # nothing yet, look again soon
Checking over and over like this is called polling. It works, but the worker wakes up 100 times a second to find nothing. A new job can also sit in the list for up to 10 milliseconds before the worker sees it. You want the worker to sleep until a job arrives, and to wake up as soon as one does.
Sleep until the data changes
A threading.Condition is a lock that also lets threads wait for a change.
A thread that holds it calls wait_for(check), where check is a function
that returns True when the thread can go on. If the check is false, the
thread releases the lock and sleeps. Another thread changes the data and
calls notify_all(), which wakes every waiting thread. Each one takes the
lock back and runs the check again.
import threading
jobs: list[str] = []
cond = threading.Condition()
def add_job(job: str) -> None:
with cond:
jobs.append(job)
cond.notify_all()
def take_job() -> str:
with cond:
cond.wait_for(lambda: len(jobs) > 0)
return jobs.pop(0)
worker = threading.Thread(target=lambda: print("got", take_job()))
worker.start()
add_job("resize cat.jpg")
worker.join() # prints: got resize cat.jpg
threading.Thread(target=f) creates a thread that will run f. start()
starts it, and join() waits until it finishes.
with cond: takes the condition's lock, as with lock: does. When
wait_for returns, the check is true and you hold the lock again, so
jobs.pop(0) is safe.
Either thread can run first. If add_job runs before the worker starts
waiting, the list already holds the job, the check is true, and wait_for
returns right away. The worker missed the notify_all(), but it does not
need it.
Check your understanding
Why does wait_for run the check again after the thread wakes up?
Show answer
Hide answer
A wake-up tells the thread only that something changed. Another worker may
have taken the job before this one got the lock back. wait_for runs the
check each time the thread wakes. If the check is false, the thread goes
back to sleep. If you call cond.wait() yourself, put it inside
while not jobs:, never inside an if.
Give up after a timeout
Pass a timeout in seconds, and wait_for returns False if the check is
still false when time runs out:
with cond:
if not cond.wait_for(lambda: len(jobs) > 0, timeout=2.0):
raise TimeoutError("no job arrived within 2 seconds")
job = jobs.pop(0)
The timeout covers the whole wait. If the thread wakes up early and goes back to sleep, it waits only for the time that is left.
Wake every waiting thread in a shared buffer
A bounded buffer is a queue with a size limit. Producers put items in,
and wait while it is full. Consumers take items out, and wait while it is
empty. Both kinds of thread can share one condition, as long as the code
calls notify_all() after every change.
notify() wakes only one waiting thread, and it can be the wrong kind. Here
is how a buffer with room for one item stalls:
- Two consumers are waiting for an item. The producer adds one, and its
notify()wakes the first consumer. - Before that consumer runs, the producer tries to add a second item. The buffer is full, so the producer waits.
- The first consumer takes the item. Its
notify()wakes the second consumer, which has waited longest. - The buffer is empty, so the second consumer goes back to sleep.
The producer is now waiting for room in an empty buffer, and no thread is
left to wake it. With notify_all(), step 3 wakes the producer too, and it
adds its item. The stall needs unlucky timing, so tests can pass many times
before it shows up. Use notify_all() from the start.
In application code, use queue.Queue(maxsize=n), which is a bounded buffer
built on conditions. The exercise has you build one to see how it works. Keep the
condition and the data inside one class, as queue.Queue does, so no caller
can forget to take the lock.
Other ways to wait
The threading module has a few more tools. The next article uses Event.
| Tool | Use it when |
|---|---|
Event |
Threads wait for a one-time signal, such as "shut down now". |
Semaphore |
At most N threads may do something at once, such as hold a database connection. |
Barrier |
A fixed number of threads must all arrive before any of them goes on. |
RLock |
A thread that already holds the lock needs to take it again, for example in a recursive call. |
Going further
See the threading documentation and the queue documentation.