Last Updated: January 31, 2026
The producer-consumer pattern is one of the most common coordination problems in concurrent systems.
One or more producers generate work (events, messages, tasks) and place it into a shared buffer or queue, while one or more consumers remove that work and process it. The challenge is doing this safely and efficiently when producers and consumers run at different speeds.
In this chapter, we'll explore how producer-consumer works, implement it with proper synchronization, and understand the subtle issues around shutdown and backpressure.
Imagine a bakery with a display shelf.
If the shelf is full, bakers cannot place more pastries and have to wait until customers take some. If the shelf is empty, customers cannot take anything and have to wait until new pastries arrive.
That’s how the producer-consumer pattern works: producers generate items, consumers process them, and the shared buffer smooths out differences in speed.
In programming terms, producers and consumers are threads. The buffer is a thread-safe queue with a fixed capacity. Producers put items into the queue. Consumers take items from the queue. When the queue is full, producers block. When the queue is empty, consumers block. This blocking provides automatic flow control without explicit coordination.
The key properties of the pattern:
In any pipeline, components have different speeds. A web scraper can fetch pages faster than a parser can process them. A sensor can generate readings faster than a network can transmit them. A user can click buttons faster than a database can record actions.
Without buffering, you have two choices:
Producer-consumer gives you a third option: absorb temporary mismatches while maintaining both throughput and correctness.
| System | How It Uses Producer-Consumer |
|---|---|
| Apache Kafka | The entire system is producer-consumer. Producers write to partitions, consumers read from them. Partitions are the buffer. |
| RabbitMQ | Message queues between publishers and subscribers. Queue depth provides buffering. |
| Go Channels | Buffered channels are exactly this pattern. make(chan int, 100) creates a buffer of 100 items. |
| Java BlockingQueue | LinkedBlockingQueue, ArrayBlockingQueue implement the buffer. Thread pool task queues use this. |
| Unix Pipes | `cat file |
The pattern has four essential components. Each one has design decisions that affect correctness and performance.
Producers generate data and put it into the buffer. They could be threads reading from a network, parsing files, or generating synthetic data.
Consumers take items from the buffer and process them. They could be threads writing to a database, sending network requests, or computing results.
The buffer is the core synchronization point. It must be thread-safe and support blocking operations.
| Buffer Size | Pros | Cons |
|---|---|---|
| Small (10-100) | Low memory, fast backpressure | Frequent blocking under burst |
| Medium (100-10000) | Good burst handling | More memory, delayed backpressure |
| Large (10000+) | Handles huge bursts | High memory, masks overload |
The right size depends on burst patterns and memory constraints. Start small and increase if you see frequent producer blocking without consumer saturation.
Producers and consumers need to coordinate. This is typically done with condition variables or blocking queue implementations.
The synchronization must ensure:
Let's trace through the lifecycle of an item from production to consumption.
The producer generates or receives an item to be processed. This happens at the producer's natural pace, independent of consumers.
The producer calls buffer.put(item). If the buffer has space, the item is added and the method returns immediately. If the buffer is full, the producer blocks.
When an item is added to a previously empty buffer, a waiting consumer is notified. This is typically done via a condition variable signal.
A waiting consumer wakes up, checks that the buffer is indeed non-empty (to handle spurious wakeups), and removes the item.
If the buffer was previously full, a waiting producer is notified that space is now available.
The consumer processes the item. This happens at the consumer's natural pace, independent of producers.
Let's implement producer-consumer from scratch, then show how to use standard library implementations.
Key Points:
while loop handles spurious wakeups (never use if)notifyAll() wakes all waiters since both producers and consumers might be waitingsynchronized ensures only one thread accesses the queue at a timeIn practice, use the standard library's blocking queue implementations.
You're building a centralized logging system. Multiple application servers generate logs. The logs need to be parsed, enriched with metadata, and stored in Elasticsearch.
| Use When | Avoid When |
|---|---|
| Producer and consumer have different speeds | Both run at exactly the same speed |
| You need to decouple components | Tight coupling is acceptable |
| You want to absorb bursts of activity | Bursts don't happen in your workload |
| Multiple producers or consumers are needed | Single thread is sufficient |
| You need graceful degradation under load | Immediate failure is preferred |