Skip to content
← All Python articles

Python explainer

Make Threads Wait Without Polling

About 5 min to read. Allow extra time to work through examples.

Make a thread sleep until another thread changes shared data, and see why a buffer shared by producers and consumers must wake every waiting thread.

This article uses Python. You can make it your practice mode to browse related content across the site.

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

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:

  1. Two consumers are waiting for an item. The producer adds one, and its notify() wakes the first consumer.
  2. Before that consumer runs, the producer tries to add a second item. The buffer is full, so the producer waits.
  3. The first consumer takes the item. Its notify() wakes the second consumer, which has waited longest.
  4. 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.

step 10 of 56

Concurrency with Threads and asyncio

Up next Build a Blocking Queue with a Condition