The idea
What it is
Producer–Consumer is the answer to a question that shows up in every concurrent program: what do I do when one part of my system makes work faster than another part can finish it? The answer is to stop letting them call each other directly. Put a queue between them. One side (the producer) only ever adds items to the queue; the other side (the consumer) only ever removes them. Neither one waits for the other to finish a task — they only ever touch the shared buffer.
Picture a restaurant kitchen pass: the shelf between the kitchen and the dining room, with room for five plates. The chef cooks and slides plates onto the pass. The waiter picks plates off it and carries them out. They never coordinate directly, never wait for each other's whole job, and neither one needs to know how fast the other is. But the shelf only fits five plates, and that is the interesting part. If the chef is quicker, the pass fills up and the chef has to stop and wait for a free spot. If the waiter is quicker, the pass empties and the waiter has to stop and wait for a plate. Those two waits are the entire pattern.
The one sentence to remember
Producer–Consumer = a bounded buffer where put() blocks while the buffer is full and take() blocks while it is empty. The bound is what gives you back-pressure: a full queue slows the fast side down instead of letting it eat all your memory.
You've already met it
A print spooler (apps produce print jobs, the printer consumes them one at a time). A log pipeline (your app threads produce log lines, one writer thread consumes and flushes them to disk). A web server accept queue (the accept loop produces sockets, a thread pool consumes them). A Kafka topic (services produce events, consumer groups read them). Every thread pool you have ever used is Producer–Consumer wearing a nice API: submit() is put(), and the worker threads are consumers looping on take().
Mechanics
How it works
Three parts, and only one of them is shared
- Producer — runs its own loop: make an item,
put()it in the buffer, repeat. It never calls the consumer and does not know how many consumers exist. - Consumer — runs its own loop:
take()an item from the buffer, process it, repeat. It never calls the producer and does not know how many producers exist. - Bounded buffer — the only shared state, and therefore the only thing that needs locking. It holds a queue of items and a maximum capacity. Every rule about safety lives inside this one object.
That last point is what makes the pattern so valuable in an interview answer: it takes a messy N-threads-touching-N-things problem and shrinks all the synchronisation into one class. Get the buffer right and both sides can be plain sequential code.
The two blocking rules
A bounded buffer has a fixed capacity, so both operations have a condition they must wait on. put() cannot add to a full buffer, and take() cannot remove from an empty one. Instead of failing or spinning in a busy loop burning CPU, the calling thread goes to sleep and is woken when the situation changes. A monitor — a lock plus one or more wait-sets attached to it — is the primitive that lets a thread release the lock and sleep atomically.
// The whole pattern, in one class. Everything shared lives here.
class BoundedBuffer<T> {
private final Queue<T> q = new ArrayDeque<>();
private final int capacity; // the BOUND
public synchronized void put(T item) throws InterruptedException {
while (q.size() == capacity) { // while, NOT if
wait(); // release lock + sleep
}
q.add(item);
notifyAll(); // "buffer is no longer empty"
}
public synchronized T take() throws InterruptedException {
while (q.isEmpty()) { // while, NOT if
wait(); // release lock + sleep
}
T item = q.remove();
notifyAll(); // "buffer is no longer full"
return item;
}
}Read the shape rather than the syntax: check the condition, sleep while it holds, do the work, then wake everyone. Every hand-rolled Producer–Consumer in every language is this, whatever the local names are — wait()/notifyAll() in Java, await()/signal_all() on a Condition in Python, cv.wait(lock, pred)/notify_all() in C++.
Use while, never if — the classic interview trap
It is tempting to write if (q.isEmpty()) wait();. That is a real bug for two reasons. First, spurious wakeups: a thread is allowed to return from wait() without anyone having signalled it. Second, and far more common, a stolen slot: you were woken because an item arrived, but between your wakeup and your re-acquiring the lock, another consumer got there first and took it. With if, you fall straight through to q.remove() on an empty queue. With while, you simply re-check and go back to sleep. Rule of thumb: wait() never proves the condition is true — it only means go and check again.
Why notifyAll() beats notify() here
In the simple design above, producers and consumers wait on the same monitor, so the wait-set contains a mix of both. notify() wakes exactly one arbitrary waiter — and it might wake a producer when what you needed was to release a consumer. That producer re-checks its while (full) condition, sees it still holds, and goes straight back to sleep, having consumed the only wakeup. Now nobody is running and nobody will be signalled again: a lost wakeup, and your program deadlocks with work still queued. notifyAll() wakes everybody, they all re-check, the wrong ones sleep again, and the right one proceeds. Slightly wasteful, always correct. The efficient alternative is not notify() — it is giving each side its own condition variable (notFull and notEmpty) so you can signal precisely the group you mean, which is exactly what ReentrantLock with two Condition objects gives you.
Why the buffer must be bounded
An unbounded queue looks friendlier — the producer never blocks, so nothing ever stalls. That is exactly the trap. If your producer is on average faster than your consumer, the queue length grows without limit, and the only thing that eventually stops it is an OutOfMemoryError or the OOM killer. Worse, latency quietly rots long before that: an item sitting behind a million others might be minutes old by the time it is processed. The bound is a feature, not a limitation. A full buffer pushes the pain backwards to the producer — that is what back-pressure means — so the system slows down honestly instead of failing catastrophically. In the prototype, the ∞ unbounded chip shows it: items spill past the box in red and nothing ever waits.
Many producers, many consumers
Nothing above assumes there is only one of each. Because all the coordination lives inside the buffer, you can run 8 producers and 3 consumers with zero code changes — that is the real reason this pattern scales. Each take() hands an item to exactly one consumer (they compete for items, they do not each get a copy — that would be publish/subscribe instead). Adding consumers is how you make a slow stage keep up; the queue length is your live signal for whether you need more.
Just use a BlockingQueue
You should be able to write the class above in an interview, and you should almost never write it in production. Every platform ships a battle-tested version: ArrayBlockingQueue/LinkedBlockingQueue in Java, queue.Queue(maxsize=...) in Python, a buffered channel make(chan T, 5) in Go, BlockingCollection<T> in C#. They handle fairness, timeouts, bulk drains and interruption — details that are easy to get subtly wrong by hand. Use Executors.newFixedThreadPool(n) and you get the whole pattern (bounded queue plus consumer threads) in a single line.
Shutdown: the poison pill
Consumers block forever on an empty queue, so you need a way to say we're done. The standard trick is a poison pill: push one special sentinel item per consumer; each consumer that takes it stops looping (and, in a shared-queue setup, puts it back for the next one). In Go you just close(ch) and the receive loop ends by itself.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
A five-slot buffer with a 🧑🍳 Producer on the left and a 🧍 Consumer on the right. Hit + Produce — a numbered 🍽 item drops into the next free slot and the caption ticks to buffer: 1/5. Fill all five and press + Produce again: the producer card turns amber and reads BLOCKED · full, and the put(): while (buffer is full) notFull.await(); line lights up — that one moment is the whole lesson. Press − Consume and item #1 leaves from the head (FIFO), the producer instantly flips back to running: a slot freed, notifyAll() woke it. Press ▶ Auto to watch it run: with 🧑🍳 producer fast the queue fills and the producer parks; click 🧍 consumer fast and the queue drains until the consumer parks at 0/5 instead. The ∞ unbounded chip removes the bound — nothing ever blocks, and the buffer spills past its box in red until memory runs out.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Fill the buffer until the producer blocks
Click + Produce five times. Numbered 🍽 items land in the slots left to right and the caption climbs to buffer: 5/5. Now click + Produce once more: no sixth item appears — the producer card turns amber and reads BLOCKED · full, and the put(): while (buffer is full) notFull.await(); line lights up. That refusal is the whole pattern: back-pressure stopped the fast side instead of overflowing.
Free one slot and watch the wakeup
With the producer still parked, hit − Consume. Item #1 leaves from the head (not the tail — that's FIFO), everything shifts down, consumed ticks to 1, and the producer flips straight back to running with the explain line 'Slot freed → producer wakes'. That's notifyAll(): the parked thread re-checks its while (full) guard, finds it false now, and proceeds. Keep hitting − Consume until 0/5 and the consumer card goes BLOCKED · empty — the mirror image.
Flip which side blocks, then remove the bound
Press ▶ Auto with 🧑🍳 producer fast selected and watch the queue fill until the producer parks. Click 🧍 consumer fast and press ▶ Auto again: now the queue drains and the consumer parks at 0/5 instead. Finally toggle the ∞ unbounded chip and run Auto once more — nothing ever blocks, the buffer spills past its box in red, and the explain line reads 'No limit → memory grows'. Hit ↺ Reset to start clean.
In practice
When to use it — and what trips people up
Reach for Producer–Consumer when...
- The two sides run at different speeds and you want the fast one to keep going instead of waiting for each slow call — a request handler that hands work off, a scraper feeding a parser.
- Work arrives in bursts but must be processed at a steady rate. The buffer absorbs the spike; the bound stops the spike from becoming an outage.
- You want to scale one stage independently — add consumers to drain a queue faster, without touching a line of producer code.
- A resource must be used by one thread at a time — a single printer, a single log file, one database connection. Funnel all work through a queue and let one consumer own the resource.
Skip it when...
- The caller needs the result right now. A queue is inherently asynchronous. If you need the answer to continue, just call the function — or use a future/promise on top.
- The work is trivially fast. Locking a queue and waking a thread can cost more than the work itself. Measure before you add a hop.
- Every consumer needs every item. That is publish/subscribe, not Producer–Consumer — here each item goes to exactly one consumer.
- Strict global ordering across many consumers matters. One queue with N consumers gives you FIFO dequeue order but concurrent, out-of-order completion. Use one consumer, or partition by key.
What it gives you
- Decoupling — producers and consumers only know the queue, so either side can be rewritten, restarted or scaled without touching the other.
- Back-pressure for free — the bound turns 'my producer is too fast' from a memory-exhaustion crash into a graceful slowdown.
- Smooths bursts — a full second of traffic spike is absorbed by the buffer and processed at a steady rate instead of overwhelming the slow stage.
- All the concurrency lives in one class — the buffer is the only shared state, so producers and consumers can be plain sequential code.
Common mistakes
- Easy to get wrong by hand —
ifinstead ofwhile,notify()instead ofnotifyAll(), or forgetting to signal at all deadlocks the whole pipeline. - Adds latency and memory — every item now waits in a queue, and the queue itself is RAM you have to size deliberately.
- Shutdown needs a plan — consumers block forever on an empty queue, so you need poison pills, interruption or a closed channel to stop them.
- Harder to debug and reason about — stack traces stop at the queue boundary, and 'why is this slow?' becomes a queue-depth question rather than a call-stack one.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
// ---- The hand-rolled version: know this for interviews ----
class BoundedBuffer<T> {
private final Queue<T> q = new ArrayDeque<>();
private final int capacity; // the BOUND
BoundedBuffer(int capacity) { this.capacity = capacity; }
public synchronized void put(T item) throws InterruptedException {
while (q.size() == capacity) { // WHILE, not if:
wait(); // spurious wakeups + stolen slots
}
q.add(item);
notifyAll(); // "no longer empty" — wake everyone
}
public synchronized T take() throws InterruptedException {
while (q.isEmpty()) { // WHILE, not if
wait();
}
T item = q.remove();
notifyAll(); // "no longer full"
return item;
}
public synchronized int size() { return q.size(); }
}
public class Demo {
private static final String PILL = "__DONE__"; // poison pill
public static void main(String[] args) throws Exception {
// ---- The version you actually ship: capacity 5, blocking built in ----
BlockingQueue<String> pass = new ArrayBlockingQueue<>(5);
Thread chef = new Thread(() -> { // producer
try {
for (int i = 1; i <= 12; i++) {
pass.put("plate #" + i); // BLOCKS while full
System.out.println("cooked plate #" + i + " (queue=" + pass.size() + ")");
Thread.sleep(60); // fast side
}
pass.put(PILL); // one pill per consumer
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
Thread waiter = new Thread(() -> { // consumer
try {
while (true) {
String plate = pass.take(); // BLOCKS while empty
if (PILL.equals(plate)) break; // shutdown signal
System.out.println(" served " + plate);
Thread.sleep(150); // slow side -> queue fills
}
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
chef.start();
waiter.start();
chef.join();
waiter.join();
System.out.println("done — chef spent most of the run blocked on a full pass");
}
}References & further reading
7 sources- Docsdocs.oracle.com
Guarded Blocks — The Java Tutorials
Oracle's own walkthrough of wait()/notifyAll() built around a producer–consumer Drop object — including exactly why the guard must be a while loop.
- Docsdocs.oracle.com
BlockingQueue (Java SE API)
The interface you should reach for instead of hand-rolling: put/take blocking semantics, capacity, and the memory-consistency guarantees spelled out.
- Docsdocs.python.org
queue — A synchronized queue class (Python docs)
Python's ready-made bounded buffer. Note maxsize, the blocking behaviour of put/get, and task_done()/join() for waiting until the work is drained.
- Docsen.cppreference.com
std::condition_variable — cppreference
The C++ primitive, with the crucial note on spurious wakeups and why the predicate overload of wait() is the safe form of the while loop.
- Articlegobyexample.com
Go by Example: Channel Buffering
Two minutes on Go's answer: a buffered channel is a bounded blocking queue baked into the language, with no lock or condition variable in sight.
- Bookjcip.net
Java Concurrency in Practice — Goetz et al.
Chapter 5 covers blocking queues and the producer–consumer pattern as the backbone of well-behaved concurrent design, including thread pools and back-pressure.
- Papercs.utexas.edu
Cooperating Sequential Processes (EWD 123) — Dijkstra
The origin: Dijkstra introduces semaphores and solves the bounded-buffer problem here. Dense, but this is where the pattern comes from.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 06
In a bounded-buffer Producer–Consumer setup, what happens when the producer calls put() on a full buffer?
question 02 / 06
Why must the wait be written as while (buffer.isEmpty()) wait(); rather than if (buffer.isEmpty()) wait();?
question 03 / 06
Producers and consumers all wait on the same monitor. Why is notifyAll() safer than notify()?
question 04 / 06
Your producer is on average faster than your consumer and you switch to an unbounded queue so put() never blocks. What is the likely outcome?
question 05 / 06
In a Producer–Consumer pipeline with one queue and three consumer threads, what happens to each item that is put on the queue?
question 06 / 06
You need consumer threads to stop cleanly at the end of a batch, but they are blocked on an empty queue. What is the standard approach?
0/6 answered