Producer-Consumer Problem: Bounded Buffer with Locks, Condition Variables, and Modern Approaches
The producer-consumer problem is the canonical concurrency interview question. Producers generate items; consumers process them; a bounded buffer mediates between them. The challenge is correct synchronization — producers must wait when the buffer is full, consumers must wait when it’s empty, and multiple producers/consumers must coordinate without race conditions or deadlock. This guide covers four standard approaches: condition variables (the textbook answer), semaphores, blocking queues (the practical answer in modern languages), and lock-free / async approaches. Working code in Python; the patterns translate directly to Java, C++, and Go.
Problem Statement
Design a thread-safe bounded buffer that supports:
put(item)— producer adds an item; blocks if buffer is fullget()— consumer removes and returns an item; blocks if buffer is empty
Multiple producers and consumers must operate concurrently without races, deadlock, or lost updates.
Approach 1: Condition Variables (Textbook Answer)
Use a single mutex protecting the buffer, plus two condition variables: one signaled when the buffer becomes non-full (wakes producers), one signaled when the buffer becomes non-empty (wakes consumers).
from collections import deque
from threading import Lock, Condition
class BoundedBuffer:
def __init__(self, capacity: int):
self.capacity = capacity
self.buffer = deque()
self.lock = Lock()
self.not_full = Condition(self.lock)
self.not_empty = Condition(self.lock)
def put(self, item) -> None:
with self.not_full:
while len(self.buffer) == self.capacity:
self.not_full.wait()
self.buffer.append(item)
self.not_empty.notify()
def get(self):
with self.not_empty:
while not self.buffer:
self.not_empty.wait()
item = self.buffer.popleft()
self.not_full.notify()
return item
# Demo
import threading
import time
import random
buf = BoundedBuffer(capacity=5)
def producer(name, count):
for i in range(count):
item = f"{name}-{i}"
buf.put(item)
print(f"Produced {item}")
time.sleep(random.random() * 0.1)
def consumer(name, count):
for _ in range(count):
item = buf.get()
print(f"{name} consumed {item}")
time.sleep(random.random() * 0.2)
threads = [
threading.Thread(target=producer, args=("P1", 10)),
threading.Thread(target=producer, args=("P2", 10)),
threading.Thread(target=consumer, args=("C1", 10)),
threading.Thread(target=consumer, args=("C2", 10)),
]
for t in threads:
t.start()
for t in threads:
t.join()
Why while instead of if in the wait loop?
Condition variables can have spurious wakeups (a thread wakes from wait() without being explicitly notified). They also wake all waiters when notify_all() is called, but only one will actually find the buffer ready. Using while rather than if ensures the condition is rechecked after wakeup — the standard rule in any condition-variable code.
Approach 2: Semaphores
Use two counting semaphores: empty_slots (counts free slots, decremented by producers) and full_slots (counts filled items, decremented by consumers). Plus a mutex for buffer access.
from threading import Semaphore, Lock
from collections import deque
class BoundedBufferSemaphore:
def __init__(self, capacity: int):
self.buffer = deque()
self.empty_slots = Semaphore(capacity)
self.full_slots = Semaphore(0)
self.mutex = Lock()
def put(self, item) -> None:
self.empty_slots.acquire()
with self.mutex:
self.buffer.append(item)
self.full_slots.release()
def get(self):
self.full_slots.acquire()
with self.mutex:
item = self.buffer.popleft()
self.empty_slots.release()
return item
Trade-off: Semaphores express “wait for available slot” naturally without explicit while-loops. The mutex still protects buffer mutations. Many textbooks (Tanenbaum, OS classics) prefer this formulation for its conceptual clarity. In practice, condition variables are equally common.
Approach 3: Blocking Queue (Practical Modern Answer)
Modern languages provide built-in thread-safe bounded queues. Don’t roll your own in production code; use the standard-library version. In Python:
from queue import Queue
class BoundedBufferStdlib:
def __init__(self, capacity: int):
self.queue = Queue(maxsize=capacity)
def put(self, item) -> None:
self.queue.put(item) # blocks if full
def get(self):
return self.queue.get() # blocks if empty
In Java: BlockingQueue (typically ArrayBlockingQueue or LinkedBlockingQueue). In Go: a buffered channel ch := make(chan T, capacity). In C++: std::queue with std::condition_variable and std::mutex, or a third-party MPMC queue.
For interview purposes, mention the stdlib option even if you implement from scratch — it shows production awareness.
Approach 4: Async / Coroutines (Modern Concurrency)
For I/O-bound producers and consumers, async/await with an asyncio queue avoids thread overhead entirely:
import asyncio
async def producer(queue: asyncio.Queue, name: str, count: int):
for i in range(count):
item = f"{name}-{i}"
await queue.put(item) # awaits if full
async def consumer(queue: asyncio.Queue, name: str, count: int):
for _ in range(count):
item = await queue.get() # awaits if empty
queue.task_done()
async def main():
queue = asyncio.Queue(maxsize=5)
await asyncio.gather(
producer(queue, "P1", 10),
producer(queue, "P2", 10),
consumer(queue, "C1", 10),
consumer(queue, "C2", 10),
)
asyncio.run(main())
Async producer-consumer is appropriate when the work itself is I/O-bound (network, file, database). For CPU-bound work, threading or multiprocessing is required because of Python’s GIL.
Common Pitfalls
Race conditions from forgotten locks
Any read or write of shared state (the buffer) must hold the mutex. Forgetting the lock around a read or a check is a classic bug — the buffer state can change between check and use.
Deadlock from wrong notification
If put notifies not_full instead of not_empty, consumers never wake. Always notify the condition that downstream waiters are blocked on.
Lost wakeups
Calling notify() while the buffer is being mutated and the lock is briefly released can leave a waiter blocked. Holding the lock while calling notify (Python’s Condition does this automatically with with) prevents this.
Wrong order of acquires
In the semaphore version, empty_slots.acquire() must come before mutex.acquire() in producers. Reversing them can cause deadlock when the buffer is full and consumers need both semaphores to make progress.
Thundering herd
Using notify_all() when only one item was added wakes all consumers, all of whom recheck the condition and most go back to sleep. Use notify() when you’ve added or removed exactly one item.
Variations and Extensions
Multiple producers, multiple consumers (MPMC)
The implementations above already handle this. The mutex serializes access; condition variables wake one or more waiters as appropriate.
Priority producer-consumer
Replace the deque with a heap; producers insert with priority; consumers always get the highest-priority item. Python’s queue.PriorityQueue handles this with the same blocking semantics.
Bounded buffer with timeouts
Add timeout parameters to put and get. Both stdlib and async versions support this. Useful when you want to fail-fast rather than block indefinitely.
Lock-free queues
For extreme performance, lock-free MPMC queues (e.g., based on Michael-Scott algorithm or LMAX Disruptor) avoid mutex overhead entirely. Substantially harder to implement correctly; usually use a battle-tested library rather than rolling your own.
Frequently Asked Questions
What’s the expected interview answer?
Condition variables with mutex, while-loops on the wait condition. Walk through both put and get, explain why while not if, and note that production code would use queue.Queue or equivalent. The interviewer wants to see you understand the synchronization primitives, not just call into stdlib.
When should I use semaphores instead of condition variables?
They’re roughly equivalent in expressive power for producer-consumer. Semaphores are conceptually cleaner when the “resource count” framing matches the problem (free slots, available items). Condition variables are more flexible — they can wait on arbitrary predicates, not just count. In practice, modern code uses queue.Queue or BlockingQueue and the choice between primitives only matters for low-level systems work.
Why is producer-consumer such a common interview question?
It exercises every important concurrency concept in a small example: mutual exclusion, condition signaling, deadlock avoidance, spurious wakeups, fair scheduling. Candidates who can implement it correctly typically understand concurrency well. Candidates who struggle reveal gaps in fundamental knowledge — locks acquired in wrong order, missing while-loops, lost wakeups.
How does this compare to Go’s channels or actor models?
Channels (Go) and actor mailboxes (Erlang, Akka) are higher-level abstractions that solve the same problem. Conceptually, a buffered channel is exactly a producer-consumer queue with built-in blocking. Channels favor message-passing style; condition variables favor shared-state style. Both are valid; modern systems often use both depending on the level of abstraction.
What’s the right way to shut down producers and consumers?
Standard pattern: producers send a sentinel (e.g., None) to signal end-of-stream. Consumers exit when they receive the sentinel. With multiple consumers, send one sentinel per consumer. Alternatively, use a “shutdown” flag checked by both producers and consumers. Avoid abruptly killing threads — that risks corrupted shared state. In Go: close(ch) on the producer side; consumers detect closure via the second receive value.
See also: Fair Server Processing • Implement a Rate Limiter • Producer-Consumer Problem
💡Strategies for Solving This Problem
Classic Concurrency Problem
Got this at Microsoft in 2023. It's one of the fundamental threading problems that every programmer should know. Tests your understanding of synchronization, race conditions, and thread safety.
The Problem
You have producers that generate data and consumers that process it. They share a bounded buffer (fixed size queue). Producers add items, consumers remove items. Need to handle:
- Buffer full: Producer must wait
- Buffer empty: Consumer must wait
- Thread safety: No race conditions
- No deadlocks or busy waiting
Why It's Important
This pattern appears everywhere in real systems:
- Web servers handling requests
- Message queues (Kafka, RabbitMQ)
- Thread pools
- Event processing systems
Naive Approach: Polling
Busy-wait loops checking if buffer has space or items. Wastes CPU cycles. Never do this in production.
Better: Locks and Condition Variables
Use mutex for thread safety and condition variables to sleep/wake threads efficiently.
Key components:
- Mutex: Protects shared buffer from concurrent access
- notFull condition: Signals when buffer has space
- notEmpty condition: Signals when buffer has items
The Pattern
Producer:
- Lock mutex
- While buffer full, wait on notFull
- Add item to buffer
- Signal notEmpty
- Unlock mutex
Consumer:
- Lock mutex
- While buffer empty, wait on notEmpty
- Remove item from buffer
- Signal notFull
- Unlock mutex
At Microsoft
They wanted to see if I understood the difference between if and while when checking conditions. Always use while - prevents spurious wakeups and handles multiple consumers.
Also asked about semaphores as alternative solution. Semaphores work but condition variables give more control.