Intermediate14 min readConcurrency & Thread Safetylive prototype

Producer–Consumer

Two roles that work at different speeds, joined by one shared queue between them. Think of a restaurant kitchen pass: the 🧑‍🍳 chef puts finished plates on a five-slot shelf, the 🧍 waiter carries them away. If the chef is faster the shelf fills and the chef has to wait; if the waiter is faster the shelf empties and the waiter has to wait. That waiting — a bounded buffer with a blocking put() and a blocking take()is the Producer–Consumer pattern.

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.

java
// 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.

try 01

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.

try 02

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.

try 03

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 — if instead of while, notify() instead of notifyAll(), 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

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