# Producer-Consumer Problem: Bounded Buffer with Locks and Modern Approaches

Source: https://www.techinterview.org/post/3233459644/write-a-program-for-producer-consumer-problem/
Updated: 2026-04-27 · techinterview.org

## 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 full

- `get()` — 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](/post/3233459683/server-to-process-fair-number-of-functions/) • [Implement a Rate Limiter](/post/3233459770/implement-a-rate-limiter/) • [Producer-Consumer Problem](/post/3233459644/write-a-program-for-producer-consumer-problem/)
