Chapter 06 · Article 37 of 55
Producer-Consumer Problem
The Producer-Consumer problem is one of the most fundamental and widely-studied concurrency problems in computer science. It describes a scenario where one or more producer thre…
Article outline16 sections on this page+
Overview
The Producer-Consumer problem is one of the most fundamental and widely-studied concurrency problems in computer science. It describes a scenario where one or more producer threads generate data and place it into a shared buffer, while one or more consumer threads retrieve and process that data. The core challenge lies in coordinating access to the shared buffer so that:
- Producers do not add data when the buffer is full (bounded buffer constraint).
- Consumers do not remove data when the buffer is empty.
- No race conditions corrupt the buffer's internal state.
This pattern appears everywhere in real systems - from operating system I/O buffers to message queues like Kafka, thread pool task queues, and logging frameworks. It was first formally described by Edsger Dijkstra in 1965 as part of his work on cooperating sequential processes, and remains the go-to example for teaching synchronization primitives.
The elegance of the pattern lies in its decoupling - producers and consumers operate at independent rates, connected only through the shared buffer. This temporal decoupling improves system throughput, enables burst absorption, and allows each side to be scaled independently. Mastering this problem gives you a deep understanding of mutual exclusion, condition synchronization, and flow control in concurrent systems.
Problem Statement
Formal Definition: Given a fixed-size (bounded) buffer shared between producer and consumer threads, design a synchronization protocol that satisfies:
- Mutual Exclusion - Only one thread modifies the buffer at any instant.
- Synchronization (Full) - A producer must block when the buffer is full and resume only when space becomes available.
- Synchronization (Empty) - A consumer must block when the buffer is empty and resume only when an item is available.
- Progress - If the buffer is neither full nor empty, producers and consumers should not be unnecessarily blocked.
- Bounded Waiting - No thread should starve indefinitely.
What can go wrong without proper synchronization:
| Failure Mode | Cause |
|---|---|
| Lost items | Producer overwrites a slot before consumer reads it |
| Duplicate consumption | Two consumers read the same slot |
| Buffer corruption | Concurrent modification of head/tail pointers |
| Deadlock | Both sides waiting on conditions that can never be signaled |
| Busy-waiting (livelock) | Spinning without yielding CPU |
ASCII Diagram
┌─────────────────────────────────┐
│ Bounded Buffer (N slots) │
│ ┌───┬───┬───┬───┬───┬───┬───┐ │
┌──────────┐ │ │ 1 │ 2 │ 3 │...│ │ │ N │ │ ┌──────────┐
│Producer 1│──┐ │ └───┴───┴───┴───┴───┴───┴───┘ │ ┌──│Consumer 1│
└──────────┘ │ │ ▲ │ │ │ └──────────┘
│ │ │ tail→ ←head │ │
┌──────────┐ │ └─────────┼───────────────┼───────┘ │ ┌──────────┐
│Producer 2│──┼────enqueue()─┘ └─dequeue()─┼──│Consumer 2│
└──────────┘ │ ▲ ▲ │ └──────────┘
│ │ │ │
┌──────────┐ │ [BLOCK if full] [BLOCK if empty] ┌──────────┐
│Producer M│──┘ │ │ └──│Consumer K│
└──────────┘ ◄─signal(notFull)─┐ ┌─signal(notEmpty)► └──────────┘
│ │
Consumer signals Producer signals
after dequeue() after enqueue()
Synchronization points:
- enqueue() - acquire lock → check "not full" condition → insert → signal "not empty" → release lock
- dequeue() - acquire lock → check "not empty" condition → remove → signal "not full" → release lock
Key Concepts & Theory
Bounded Buffer
A fixed-capacity circular queue (ring buffer). The size N determines the degree of decoupling between producers and consumers. Larger buffers absorb bursts; smaller buffers enforce tighter coupling.
Blocking vs Non-Blocking
| Aspect | Blocking | Non-Blocking |
|---|---|---|
| Mechanism | Thread sleeps on condition variable | Returns immediately with failure/retry |
| CPU usage | Efficient (no spinning) | May spin or poll |
| Complexity | Simpler mental model | Requires CAS loops, ABA handling |
| Use case | General-purpose | Ultra-low-latency (e.g., LMAX Disruptor) |
Back-Pressure
When the buffer is full, the producer is forced to slow down. This is back-pressure - a natural flow-control mechanism that prevents unbounded memory growth and propagates load information upstream.
Flow Control
The bounded buffer acts as a rate-matching device. If producers are faster than consumers, the buffer fills and producers block. If consumers are faster, the buffer empties and consumers block. The system self-regulates without external coordination.
Implementation Approaches
Approach 1: Using wait/notify (Condition Variables)
shared:
buffer[N] // circular array
count = 0 // current items
mutex = Lock() // mutual exclusion
producer():
item = produce()
mutex.lock()
while count == N: // buffer full - wait
wait(mutex, notFull) // atomically release lock + sleep
buffer[tail] = item
tail = (tail + 1) % N
count++
signal(notEmpty) // wake one waiting consumer
mutex.unlock()
consumer():
mutex.lock()
while count == 0: // buffer empty - wait
wait(mutex, notEmpty) // atomically release lock + sleep
item = buffer[head]
head = (head + 1) % N
count--
signal(notFull) // wake one waiting producer
mutex.unlock()
consume(item)
Why
whilenotif? Spurious wakeups can occur; the condition must be re-checked after waking.
Approach 2: Using Semaphores (Dijkstra's Classic)
shared:
buffer[N]
empty = Semaphore(N) // counts empty slots (initially N)
full = Semaphore(0) // counts filled slots (initially 0)
mutex = Semaphore(1) // binary semaphore for mutual exclusion
producer():
item = produce()
empty.wait() // decrement empty; block if 0 (buffer full)
mutex.wait() // enter critical section
buffer[tail] = item
tail = (tail + 1) % N
mutex.signal() // leave critical section
full.signal() // increment full; wake consumer if blocked
consumer():
full.wait() // decrement full; block if 0 (buffer empty)
mutex.wait() // enter critical section
item = buffer[head]
head = (head + 1) % N
mutex.signal() // leave critical section
empty.signal() // increment empty; wake producer if blocked
consume(item)
Key insight: Separating capacity tracking (empty/full semaphores) from mutual exclusion (mutex) allows producers and consumers to block on capacity without holding the mutex.
Approach 3: Using BlockingQueue (High-Level Abstraction)
shared:
queue = BlockingQueue(capacity=N)
producer():
item = produce()
queue.put(item) // blocks if full; handles all synchronization internally
consumer():
item = queue.take() // blocks if empty; handles all synchronization internally
consume(item)
Languages provide this out of the box: Java's
ArrayBlockingQueue, Python'squeue.Queue, Go's buffered channels (make(chan T, N)).
Approach 4: Using Lock + Condition (Explicit Conditions)
shared:
buffer[N]
count = 0
lock = ReentrantLock()
notFull = lock.newCondition()
notEmpty = lock.newCondition()
producer():
item = produce()
lock.lock()
try:
while count == N:
notFull.await() // release lock, wait on notFull
buffer[tail] = item
tail = (tail + 1) % N
count++
notEmpty.signal() // wake exactly one consumer
finally:
lock.unlock()
consumer():
lock.lock()
try:
while count == 0:
notEmpty.await() // release lock, wait on notEmpty
item = buffer[head]
head = (head + 1) % N
count--
notFull.signal() // wake exactly one producer
finally:
lock.unlock()
consume(item)
Advantage over wait/notify: Separate condition objects (
notFull,notEmpty) allow targeted signaling - no thundering herd fromnotifyAll.
Single Producer Single Consumer vs Multiple
| Aspect | SPSC | MPMC |
|---|---|---|
| Synchronization | Can use lock-free ring buffer (atomic head/tail) | Requires mutex or CAS loops |
| Ordering | FIFO guaranteed trivially | FIFO per-producer; global order needs sequencing |
| Performance | Highest throughput, cache-friendly | Contention on shared state |
| Complexity | Simple | Must handle lost wakeups, fairness |
| Example | LMAX Disruptor (single writer) | Java ThreadPoolExecutor work queue |
Additional challenges with multiple producers/consumers:
- Lost wakeup: A signal intended for a producer accidentally wakes another producer (solved by
signalAllor separate conditions). - Fairness/starvation: One fast consumer may starve others; fair locks or round-robin help.
- Ordering guarantees: If global ordering matters, a single sequencer or timestamp is needed.
Bounded vs Unbounded Buffer
| Criteria | Bounded Buffer | Unbounded Buffer |
|---|---|---|
| Memory usage | Predictable, capped at N | Can grow without limit |
| Back-pressure | Natural - producer blocks when full | None - producer never blocks |
| OOM risk | None (by design) | High under sustained load |
| Latency | May spike when producer blocks | Consistent for producer; consumer may lag |
| Flow control | Built-in | Must be implemented externally (rate limiting) |
| Use case | Production systems, safety-critical | Low-volume or short-lived tasks |
| Implementation | Ring buffer, array-backed queue | Linked list, dynamic array |
Rule of thumb: Always prefer bounded buffers in production. Unbounded buffers hide problems until the system runs out of memory.
Real-World Examples
| System | Role of Pattern | Buffer Mechanism |
|---|---|---|
| Apache Kafka | Producers publish to topic partitions (bounded by buffer.memory); consumers poll in groups | On-disk log segments with configurable retention |
| RabbitMQ | Publishers → exchange → bounded queues → consumers with prefetch limits | In-memory queue with optional disk overflow |
| Thread Pool (ExecutorService) | Submitting threads are producers; worker threads are consumers of a task queue | ArrayBlockingQueue or LinkedBlockingQueue |
| I/O Buffers | Disk/network writes produce into kernel ring buffers; DMA/NIC consumes | Kernel ring buffer (io_uring, epoll) |
| Log4j AsyncAppender | Application threads produce log events; background thread consumes and writes to disk | ArrayBlockingQueue with configurable discard policy |
| Go Channels | ch := make(chan int, 100) - buffered channel is literally a bounded producer-consumer buffer | Runtime channel implementation (ring buffer) |
| Reactive Streams | Publisher/Subscriber with request(n) back-pressure is a generalized producer-consumer | Internal buffers with demand signaling |
| Node.js Streams | Readable streams produce chunks; writable streams consume with highWaterMark back-pressure | Internal Buffer with drain events |
Deep Dive: Kafka as Producer-Consumer
Kafka exemplifies the pattern at distributed scale. The producer's buffer.memory setting (default 32MB) acts as the bounded buffer - when full, send() blocks for up to max.block.ms. On the consumer side, max.poll.records controls batch size, and consumer group rebalancing handles multiple consumers. The partition acts as the unit of parallelism: within a partition, strict FIFO ordering is maintained; across partitions, only eventual consistency is guaranteed.
Deep Dive: Thread Pool Task Queue
Java's ThreadPoolExecutor accepts a BlockingQueue<Runnable> as its work queue. When all core threads are busy and the queue is full, the rejection policy kicks in (CallerRunsPolicy, AbortPolicy, etc.) - this is the back-pressure mechanism. The choice of queue type (ArrayBlockingQueue for bounded, SynchronousQueue for direct handoff, LinkedBlockingQueue for unbounded) directly maps to the bounded vs unbounded buffer decision.
Variations
Multiple Producers / Multiple Consumers (MPMC)
Standard extension. Requires careful synchronization - typically a single shared queue with mutex, or partitioned queues (one per consumer) with work-stealing.
Priority-Based Consumption
Replace FIFO buffer with a priority queue. Consumers always take the highest-priority item. Requires a concurrent priority queue (e.g., PriorityBlockingQueue in Java). Trade-off: no strict FIFO ordering; starvation of low-priority items possible.
Batch Consumption
Consumer drains multiple items at once (drainTo(collection, maxElements)). Reduces lock acquisition overhead and improves throughput for I/O-bound consumers (e.g., batch database inserts). Trade-off: increased latency for individual items.
Poison Pill (Graceful Shutdown)
Producer inserts a sentinel value indicating "no more items." Consumer detects it and terminates. For MPMC, producer inserts one poison pill per consumer, or consumers re-insert the pill after detecting it.
Advantages & Disadvantages of Each Approach
| Approach | Advantages | Disadvantages | Best For |
|---|---|---|---|
| wait/notify | Simple, no extra libraries | Prone to lost wakeups with multiple waiters; single condition | Educational, SPSC |
| Semaphores | Elegant separation of concerns; no spurious wakeup loops needed for capacity | Three separate primitives to manage; ordering of waits matters (deadlock risk if reversed) | OS-level, C/C++ |
| BlockingQueue | Minimal code; battle-tested; handles all edge cases | Less control over internals; may not suit custom scheduling | Production Java/Python/Go |
| Lock + Condition | Targeted signaling; composable with other conditions | Verbose; must remember try/finally unlock | Complex coordination, MPMC |
| Lock-free (CAS) | Highest throughput; no context switches | Extremely hard to get right; ABA problem; limited to SPSC or specialized structures | Ultra-low-latency (HFT, Disruptor) |
Constraints & Edge Cases
Buffer Full (Producer Blocks)
Producer must not busy-wait. It should release the lock and sleep on a condition. Upon waking, it must re-check the condition (while-loop) due to spurious wakeups or race with other producers.
Buffer Empty (Consumer Blocks)
Symmetric to above. Consumer sleeps on notEmpty and re-checks after waking.
Graceful Shutdown (Poison Pill)
- Define a sentinel value (e.g.,
null, special object, or enum). - Producer enqueues the poison pill as its last action.
- Consumer checks each dequeued item; if poison pill, it cleans up and exits.
- For M consumers: either insert M poison pills, or have each consumer re-enqueue the pill before exiting.
Spurious Wakeups
POSIX and Java specifications allow wait()/await() to return without a corresponding signal(). Always use:
while (condition_not_met):
wait()
Never use if.
Deadlock from Incorrect Semaphore Ordering
If a producer calls mutex.wait() before empty.wait(), it holds the mutex while blocking on capacity - consumers cannot acquire the mutex to free space → deadlock. Always acquire capacity semaphore first.
Concurrency Patterns Summary
Since this is the final article in the Multithreading & Concurrency section, here is a comparative overview of the key concurrency patterns covered throughout this section:
| Pattern | Problem Solved | Key Primitives | Blocking Behavior | Typical Use Case |
|---|---|---|---|---|
| Producer-Consumer | Decouple data generation from processing with flow control | Mutex + Conditions / Semaphores / BlockingQueue | Producer blocks on full; Consumer blocks on empty | Task queues, message brokers, I/O buffers |
| Reader-Writer | Allow concurrent reads while ensuring exclusive writes | ReadWriteLock / shared-exclusive semaphore | Writers block if readers active; readers block if writer active | Caches, config stores, databases |
| Dining Philosophers | Avoid deadlock when multiple threads compete for multiple shared resources | Mutexes with ordering / resource hierarchy | Philosopher blocks waiting for both forks | Resource allocation, lock ordering education |
| Barrier | Synchronize N threads at a common point before proceeding | CyclicBarrier / CountDownLatch / Phaser | All threads block until last one arrives | Parallel computation phases, MapReduce |
| Future/Promise | Represent a value that will be available asynchronously | Future + CompletionHandler / async-await | Caller blocks (or chains callback) until result ready | Async I/O, RPC calls, pipeline stages |
| Monitor | Encapsulate shared state with built-in synchronization | Intrinsic lock + condition variables | Threads block on entry or on condition | Object-level synchronization (Java synchronized) |
| Semaphore (Counting) | Limit concurrent access to a resource pool | Counting semaphore | Thread blocks when count reaches 0 | Connection pools, rate limiting |
How these patterns relate to each other:
- The Producer-Consumer pattern often uses Semaphores internally and can be built on top of Monitors.
- The Reader-Writer pattern is a specialization of access control that complements Producer-Consumer when the buffer supports concurrent reads.
- Barriers are useful within parallel producer or consumer groups to synchronize phases of work.
- Futures/Promises can represent the result of a consumer's processing, enabling asynchronous pipelines built on top of producer-consumer queues.
Interview Follow-ups
Q1: How would you implement a producer-consumer system that guarantees message ordering across multiple consumers?
Answer: Use a single-partition design (one queue, one consumer) for strict ordering. For parallelism with ordering, partition by key (like Kafka's partition key) - all messages with the same key go to the same partition/consumer. Within a partition, FIFO is maintained. Across partitions, only causal ordering (via vector clocks or sequence numbers) can be guaranteed.
Q2: What happens if a consumer crashes after dequeuing but before processing?
Answer: The item is lost unless you implement acknowledgment-based consumption. The item remains "in-flight" until the consumer sends an ACK. If no ACK within a timeout, the broker re-delivers to another consumer. This is the model used by RabbitMQ (manual ack), SQS (visibility timeout), and Kafka (commit offset after processing).
Q3: How do you choose the buffer size?
Answer: Buffer size is a trade-off between memory usage and burst absorption. Profile the production and consumption rates. If production is bursty, size the buffer to absorb the peak burst duration: buffer_size ≈ (peak_production_rate - consumption_rate) × burst_duration. Too small → frequent producer blocking; too large → wasted memory and delayed back-pressure signals.
Hint-Only Questions
H1: How would you implement a priority-based producer-consumer where high-priority items are consumed first?
- Hint: Think about replacing the FIFO queue with a concurrent priority queue. Consider starvation of low-priority items and how aging or deadline-based promotion could help.
H2: Design a producer-consumer system that supports graceful shutdown without losing any in-flight messages.
- Hint: Consider a two-phase shutdown: (1) stop accepting new items (close producers), (2) drain the buffer completely before terminating consumers. Think about how poison pills interact with acknowledgment semantics.
Counter Questions to Ask Interviewer
-
"Is there a single producer or multiple? Single consumer or multiple?" - This fundamentally changes the synchronization approach and whether lock-free structures are viable.
-
"Is the buffer bounded or unbounded? If bounded, what's the expected size?" - Determines whether back-pressure is needed and influences memory planning.
-
"What are the ordering guarantees required?" - Strict FIFO, per-key ordering, or best-effort? This affects partitioning and consumer design.
-
"What happens on failure - is at-least-once or exactly-once delivery required?" - Drives the need for acknowledgments, idempotency, and deduplication.
-
"What are the relative rates of production vs consumption?" - Helps size the buffer and decide if batching or multiple consumers are needed.
-
"Is this in-process (threads sharing memory) or distributed (network boundary)?" - In-process uses locks/queues; distributed uses message brokers with different failure modes.
References & Whitepapers
- Dijkstra, E.W. - "Cooperating Sequential Processes" (1965) - Introduced semaphores and the bounded buffer problem as a canonical synchronization example.
- Hoare, C.A.R. - "Communicating Sequential Processes" (1978) - Formalized channel-based communication (foundation for Go channels and CSP-style concurrency).
- Goetz, B. et al. - "Java Concurrency in Practice" (2006) - Chapters 5 and 14 cover BlockingQueue patterns and condition queues in depth.
- Herlihy, M. & Shavit, N. - "The Art of Multiprocessor Programming" (2008) - Lock-free queue algorithms (Michael-Scott queue) and linearizability.
- Thompson, M. et al. - "Disruptor: High Performance Alternative to Bounded Queues" (LMAX, 2011) - Lock-free ring buffer for ultra-low-latency producer-consumer.
- Lamport, L. - "Proving the Correctness of Multiprocess Programs" (1977) - Formal verification techniques applicable to bounded buffer correctness.