Vasu Patel logoVasu Patel
← Sharing Time: Concurrency & Multithreading
All chapters in this track
  1. 01Chapter 1: Concurrency vs. Parallelism, and Why It's Hard
  2. 02Chapter 2: Processes vs. Threads, Revisited for Concurrency
  3. 03Chapter 3: The Core Hazards: Race Conditions, Deadlock, Livelock, Starvation
  4. 04Chapter 4: Synchronization Primitives: Locks, Semaphores, Monitors, Condition Variables
  5. 05Chapter 5: Designing for Thread Safety: Immutability, Confinement, Thread-Safe Collections
  6. 06Chapter 6: Classic Patterns: Producer-Consumer, Reader-Writer, Thread Pools
  7. 07Chapter 7: Higher-Level Models: Async/Await, Event Loops, Coroutines, Actors, Channels
  8. 08Chapter 8: Memory Models and Visibility: Happens-Before, Volatile, Atomics
  9. 09Chapter 9: Lock-Free and Wait-Free Programming Basics
  10. 10Chapter 10: How Concurrency Differs Across Runtimes: JVM Threads, Python's GIL, Go's Goroutines, JavaScript's Event Loop
  11. 11Chapter 11: Debugging and Testing Concurrent Code
  12. 12Chapter 12: Classic Interview Problem: Producer-Consumer
  13. 13Chapter 13: Classic Interview Problem: Dining Philosophers
  14. 14Chapter 14: Classic Interview Problem: Readers-Writers
  15. 15Chapter 15: Concurrency in System Design and Production Debugging

Chapter 12 of 15

Chapter 12: Classic Interview Problem: Producer-Consumer

Why this matters

Producer-consumer (Chapter 6) is the single most commonly requested "implement this from scratch, using only basic primitives" concurrency interview problem — specifically because interviewers want to see whether you understand condition variables (Chapter 4) at a mechanical level, not whether you know queue.Queue exists. This chapter builds the from-scratch implementation, deliberately, with every design decision narrated.

The problem, precisely stated

"Implement a bounded buffer supporting put(item) and get(), safe for multiple producer and consumer threads, without using a pre-built thread-safe queue."

Building it from scratch, decision by decision

A naive first attempt — busy-waiting, which works but wastes CPU:

import threading
import time

class NaiveBoundedBuffer:
    def __init__(self, capacity):
        self._lock = threading.Lock()
        self._items = []
        self._capacity = capacity

    def put(self, item):
        while True:
            with self._lock:
                if len(self._items) < self._capacity:
                    self._items.append(item)
                    return
            time.sleep(0.001)  # BUSY-WAITING: repeatedly checking, wasting CPU while waiting

    def get(self):
        while True:
            with self._lock:
                if self._items:
                    return self._items.pop(0)
            time.sleep(0.001)  # same problem

What a strong candidate says here: "This is correct, but it busy-waits — every waiting thread repeatedly wakes up, acquires the lock, checks, and sleeps again, wasting CPU cycles that could be doing useful work elsewhere. I'd use a condition variable instead, so a waiting thread genuinely sleeps until it's specifically notified that something changed, rather than polling."

The correct implementation, using condition variables (Chapter 4):

import threading

class BoundedBuffer:
    def __init__(self, capacity):
        self._lock = threading.Lock()
        self._not_full = threading.Condition(self._lock)
        self._not_empty = threading.Condition(self._lock)
        self._items = []
        self._capacity = capacity

    def put(self, item):
        with self._not_full:
            while len(self._items) >= self._capacity:  # WHILE, not if -- guards against
                self._not_full.wait()                    # spurious wakeups, per Chapter 4
            self._items.append(item)
            self._not_empty.notify()  # wake ONE waiting consumer, since exactly one item became available

    def get(self):
        with self._not_empty:
            while len(self._items) == 0:
                self._not_empty.wait()
            item = self._items.pop(0)
            self._not_full.notify()  # wake ONE waiting producer, since exactly one slot became available
            return item

The design decisions a strong candidate narrates explicitly

[SENIOR INSIGHT]

  1. Why two condition variables instead of one? Using a single condition variable would work correctly (since notify_all() could wake every waiter, letting each re-check its own specific condition), but using two — one for "not full" (producers wait here) and one for "not empty" (consumers wait here) — means notify() only wakes a thread that could actually plausibly proceed, rather than waking every waiter (including ones that still can't proceed) and making them all re-check pointlessly. This is a genuine, deliberate efficiency choice worth explaining, not an arbitrary implementation detail.

  2. Why notify() instead of notify_all()? Since exactly one slot/item became available, waking exactly one waiting thread is sufficient and avoids the "thundering herd" of waking every waiter just to have all but one immediately re-check and go back to waiting. notify_all() would still be correct (just less efficient) — worth mentioning this trade-off explicitly if asked.

  3. Why while instead of if around wait()? Directly Chapter 4's spurious-wakeup guard — a woken thread must re-verify its condition is actually true before proceeding, since a wakeup doesn't guarantee the specific condition it's waiting for has become true (another thread could have raced ahead and consumed the just-added item before this thread got scheduled).

Handling shutdown cleanly

[IN PRACTICE] A complete answer should also address how consumers know to stop — the "poison pill" pattern from Chapter 6 is the standard, simple answer, worth mentioning even if not explicitly asked:

SENTINEL = object()  # a unique, unmistakable "stop" marker

def consumer(buffer):
    while True:
        item = buffer.get()
        if item is SENTINEL:
            break
        process(item)

Common mistakes

  1. Using if instead of while around the wait() call — the single most common, most important bug in a from-scratch implementation of this problem, and something interviewers specifically watch for.
  2. Using one lock but forgetting condition variables entirely, falling back to the busy-waiting version without recognizing it as an inefficiency worth fixing — a strong candidate proactively identifies and improves on this, rather than needing to be prompted.
  3. Not explaining the reasoning behind design choices (two condition variables vs. one, notify() vs. notify_all()) — as with the OOP book's Chapter 18 interview guidance, narrating the why behind each decision is a stronger signal than a working implementation delivered silently.
  4. Forgetting to handle graceful shutdown — a fully "correct" bounded buffer that never terminates cleanly is an incomplete answer to how this pattern is actually used in real systems.

Discussion / interview questions

  1. "Implement a thread-safe bounded buffer from scratch, without using a library queue." Strong answer: Produces the condition-variable-based implementation above, explicitly narrating why while (not if) guards the wait() calls, and why the design uses two condition variables rather than one.

  2. "Why use two separate condition variables instead of one for this problem?" Strong answer: Using one condition variable would require notify_all() to wake every waiter (since a single condition variable can't distinguish "producer waiting for space" from "consumer waiting for an item"), causing unnecessary wakeups and re-checks; two condition variables let notify() target exactly the kind of waiter that can plausibly proceed.

  3. "What would happen if you used if instead of while around the wait() calls in your implementation?" Strong answer: A spurious or stale wakeup could let a thread proceed without re-verifying its condition is actually true — e.g., a consumer could wake up believing an item is available, but another consumer might have already taken it first, leading to an incorrect state or an exception when trying to pop from an empty list.

  4. "How would you cleanly signal all consumer threads to stop processing?" Strong answer: A "poison pill" pattern — pushing a unique sentinel value (one per consumer thread) through the buffer, which each consumer recognizes as a stop signal and exits its processing loop upon receiving, rather than an external flag that could race with in-flight get() calls.

Chapter summary / cheat sheet

  • The producer-consumer interview problem tests condition variable mechanics specifically — a working busy-wait solution is a good starting point, but recognizing and fixing its CPU-wasting inefficiency with condition variables is the actual signal being evaluated.
  • Two condition variables (not-full, not-empty) let notify() target the specific kind of waiter that can plausibly proceed, rather than waking everyone.
  • while (never if) around every wait() call, guarding against spurious/stale wakeups — the single most-watched-for correctness detail.
  • A complete answer addresses graceful shutdown (the poison pill pattern), not just the core put/get mechanics.