Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Producer-Consumer with Threads and Queues

Hello! Welcome to your next lesson on Python concurrency.

In our last session, we contrasted coarse-grained and fine-grained locking, establishing that while fine-grained locks can boost performance, they introduce complexity. We concluded that locks are excellent for ensuring mutual exclusion—preventing threads from interfering with each other's work on a shared resource.

However, threads often need more sophisticated coordination than simply taking turns. One thread might need to pause and wait for another to complete a task or produce a piece of data. This brings us to one of the most fundamental patterns in concurrent programming: the Producer-Consumer pattern.

Your work on the Rudi inference scheduler at AWS is a perfect real-world example of this pattern. An external system (or another part of your application) "produces" inference requests, placing them into a queue. A pool of worker processes "consumes" these requests, performs the inference, and returns results.

Today's lesson will formalize this concept. Our learning outcome is to implement the producer-consumer pattern using threading with thread-safe queues and condition variables. We will explore two ways to build this: a high-level approach using Python's built-in tools and a low-level approach that gives you full control, which is often required in coding interviews.

1. The Producer-Consumer Pattern: A High-Level View

The pattern decouples the task of producing work from the task of consuming it. This is achieved by using a shared, intermediate buffer or queue.

  • Producer: A thread or process that creates data or work items and puts them into the buffer.
  • Consumer: A thread or process that retrieves items from the buffer and processes them.
  • Shared Buffer: A data structure (typically a queue) that holds the items, coordinating access between producers and consumers.
Producer-Consumer Pattern with Python Threads and FIFO Queue
This diagram shows the classic producer-consumer flow. A producer thread adds items to a shared FIFO queue, and a consumer thread independently retrieves and processes them. The main thread coordinates their lifecycle.

This decoupling offers several key advantages, which are critical in systems like Rudi:

  • Rate Buffering: It smooths out mismatched rates between production and consumption. If you have a burst of incoming requests, the queue can absorb them, allowing the consumers to process them at a steady pace.
  • Scalability: You can independently scale the number of producers or consumers to match workload demands. If processing is the bottleneck, you add more consumers.
  • Decoupling: Producers and consumers don't need direct knowledge of each other. They only need to agree on the data format and the shared queue.

2. Implementation with queue.Queue: The Practical Approach

For most practical applications in Python, the producer-consumer pattern is best implemented using the queue module. The queue.Queue class is a thread-safe, blocking First-In, First-Out (FIFO) queue that handles all the complex locking and waiting for you.

  • q.put(item): Adds an item to the queue. If the queue is full (if a maxsize was specified), it blocks until space is available.
  • q.get(): Removes and returns an item from the queue. If the queue is empty, it blocks until an item is available.

Creating and sharing data between Python Threads using data structures like queue, locks & events

To see this in action, let's watch a video that builds a simple producer-consumer system from scratch using queue.Queue. Pay close attention to how the two threads communicate only through the shared queue and how the program handles termination.

Please watch the sections explaining Python's queue module and then the code implementation. The relevant parts are from 48:53 to 54:47.

Graceful Shutdown: The "Poison Pill"

A critical aspect of the producer-consumer pattern is knowing when to stop the consumers. If a consumer is in an infinite loop calling q.get(), it will block forever once all the work is done.

The video demonstrates a common and robust technique called the "poison pill". After the producer has finished adding all its work items, it puts a special sentinel value (like None) onto the queue.

When the consumer retrieves this None value, it interprets it as a signal to shut down gracefully.

Producer-Consumer Pattern | Low Level Design Mastery

The poison pill is a widely used pattern for managing the lifecycle of consumer threads, especially when you have multiple consumers. This article provides a clear Python example.

Read the short section titled "Poison Pill Pattern". Notice how in the multi-consumer example, a consumer that receives the pill puts it back on the queue before exiting. This ensures that other consumers also receive the signal.

The queue.Queue approach is simple, robust, and idiomatic. In any production scenario where a standard queue is sufficient, this should be your first choice.

3. Implementation with Condition Variables: The Low-Level Approach

While queue.Queue is convenient, interviews at companies like Anthropic often test your understanding of the underlying mechanics. You might be asked to implement a blocking queue from scratch. This requires a more powerful synchronization primitive than a simple lock: a condition variable.

A lock answers the question, "Can I access the resource now?" A condition variable helps answer the question, "Can I proceed now, or should I wait for a specific condition to become true?"

Condition Variables, Producer-consumer problem, Wait/Notify patterns

Let's start by understanding why locks alone are insufficient and what a condition variable provides. This article gives an excellent conceptual overview.

Read the sections "Why Locks Alone Are Not Enough", "What Is a Condition Variable?", and "Producer–Consumer Problem Explained". Focus on the core idea that a condition variable allows a thread to sleep without holding the lock until it's notified.

A threading.Condition object is always associated with a lock. Its key methods are:

  • condition.wait(): This atomically releases the associated lock and puts the thread to sleep until it is notified by another thread. Once awakened, it automatically reacquires the lock before proceeding.
  • condition.notify(n=1): Wakes up n threads that are waiting on the condition.
  • condition.notifyAll(): Wakes up all threads waiting on the condition.

Implementing a Bounded Buffer

Let's implement a BoundedBuffer class that functions like a blocking queue, using a lock and two condition variables: one for when the buffer is not full, and one for when it is not empty. This separation is more efficient than using a single condition variable.

Producer-Consumer Pattern | Low Level Design Mastery

This article provides a canonical, interview-quality implementation of a bounded buffer using condition variables. This is a common pattern you should be familiar with.

Focus on the Python implementation under the section "Implementation Approach 1: Low-Level (Condition Variables)". Analyze the put and get methods and note how they use not_full and not_empty conditions.

Let's break down the logic in the put method from that example:




# From the Low Level Design Mastery resource
def put(self, item):
    with self.lock:



        # 1. Wait while the buffer is full
        while len(self.buffer) >= self.capacity:
            self.not_full.wait() # Releases lock, waits




        # 2. Add the item
        self.buffer.append(item)
        print(f"Produced: {item}, Buffer size: {len(self.buffer)}")




        # 3. Notify a waiting consumer
        self.not_empty.notify()

This sequence is the heart of the pattern:

  1. Acquire the lock.
  2. Check the condition in a while loop. If the condition isn't met, wait().
  3. Perform the action.
  4. notify() any threads that might be waiting for the state change you just made.

The Most Important Rule: wait() in a while loop

You might wonder why we use while len(self.buffer) >= self.capacity: instead of a simple if. This is arguably the most critical and frequently missed detail when working with condition variables. A thread can wake up from wait() for reasons other than a notify() call. These are called spurious wakeups.

Condition Variables, Producer-consumer problem, Wait/Notify patterns

You must always re-check your condition after waking up from a wait. This article explains the phenomenon of spurious wakeups and why the while loop is non-negotiable.

Please read the section titled "Why to use ‘while’ and not ‘if’". This is a crucial concept for correctness in concurrent programming.

Using a while loop ensures that even if your thread wakes up spuriously, it will re-evaluate the condition (len(self.buffer) >= self.capacity) and go back to sleep if the buffer is still full. An if statement would incorrectly proceed, violating the buffer's capacity constraint.

4. Trade-Offs: queue.Queue vs. Condition Variables

Your choice of implementation depends on the requirements:

Feature queue.Queue threading.Condition
Abstraction Level High-level Low-level
Simplicity Very simple. Handles all synchronization internally. Complex. You manage the lock, state, and waiting/notifying logic yourself.
Flexibility Limited to standard queue semantics (FIFO, LIFO, Priority). Highly flexible. Can be used to build any custom synchronization logic.
Risk of Error Low. Hard to misuse. High. Prone to subtle bugs like lost wakeups, deadlock, and forgetting the while loop.
Use Case The default choice for producer-consumer problems in Python. Implementing custom synchronization primitives, or when required in an interview setting.

In your experience with Rudi, the underlying mechanism is likely a sophisticated queue, but its behavior (blocking when full/empty, notifying workers) is exactly what we've modeled here with condition variables.

Conclusion

Today, we took a deep dive into the producer-consumer pattern, a cornerstone of concurrent systems. You saw how it decouples work generation from processing, enabling scalable and resilient designs.

Key Takeaways:

  • The Producer-Consumer pattern uses a shared buffer to decouple threads, smoothing out workloads and improving scalability.
  • Python's queue.Queue is the preferred high-level tool for this pattern. It's thread-safe and handles all the blocking and notification logic for you.
  • A poison pill is a common and effective pattern for gracefully shutting down consumer threads.
  • threading.Condition is the underlying low-level primitive that enables this coordination. It allows threads to wait efficiently for a specific state change.
  • Crucial Rule: Always re-check the condition in a while loop after condition.wait() to guard against spurious wakeups and other race conditions.

Next Lesson Preview

So far in this module, we've focused entirely on the threading library, which is ideal for I/O-bound tasks but is limited by the Global Interpreter Lock (GIL) for CPU-bound work. Python offers another, fundamentally different model for concurrency: asyncio.

In our next lesson, we will begin our exploration of this new paradigm. You will write asynchronous code using asyncio coroutines, tasks, and event loops, and we'll start to map the concurrency patterns you know from Rust's Tokio to their asyncio equivalents.

Can't find a good explanation? Sign up and we'll make it for you

Sign up