Engineering Atlas › Low-Level Design

Producer-consumer with a shared buffer

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem

A log shipper reads lines from application files and sends them in batches to a central store. Reading is fast and bursty: a deploy can produce 50,000 lines in a second. Sending is slower and steady: the store accepts about 5,000 lines per second from one shipper. If the reader calls the sender directly, every read waits for a network round trip, and a slow store stalls the reader. If the reader appends to an unbounded list that the sender drains, a ten-minute store outage queues millions of lines and the shipper runs out of memory.

The producer-consumer pattern puts a bounded buffer between them. The reader (producer) puts lines in; the sender (consumer) takes them out. Short bursts are absorbed by the buffer. A long slowdown fills it, and then the producer waits, which in a log shipper means it simply reads the files later. Nothing is lost, and memory stays flat.

The three rules of a bounded buffer

  1. A producer may add only when there is room; otherwise it waits (or follows an overflow policy).
  2. A consumer may take only when there is an item; otherwise it waits.
  3. Adding and taking must not interfere: two producers must not write the same slot, and a consumer must never see a half-written item.

The classic implementation

A ring buffer guarded by one mutex and two condition variables:

def put(self, item):
    with self.lock:
        while len(self.items) == self.capacity:
            self.not_full.wait()       # releases the lock while waiting
        self.items.append(item)
        self.not_empty.notify()

def take(self):
    with self.lock:
        while not self.items:
            self.not_empty.wait()
        item = self.items.popleft()
        self.not_full.notify()
        return item

The while is not optional. A waiting thread can wake up spuriously, and even a genuine notify does not guarantee the condition still holds: another consumer may have grabbed the item between the notify and this thread reacquiring the lock. Checking with if instead of while is the most common bug in hand-written versions. Using one condition variable for both conditions also works, but then every change must notify_all, waking producers that cannot proceed.

Dijkstra's original formulation used two counting semaphores, empty_slots (starting at capacity) and full_slots (starting at 0), plus a mutex for the buffer itself. A producer waits on empty_slots, adds, signals full_slots; a consumer does the reverse. The counts carry the state, so no while loop is needed.

Overflow policies

Blocking is only one answer to "the buffer is full". The others are the same menu a thread pool offers:

Shutdown

Consumers loop forever, so they need a way to know the stream has ended. Go's close(ch) makes receivers drain the remaining items and then get the zero value with ok == false; sending on a closed channel panics. In Java and Python, the convention is a poison pill: a special sentinel object put once per consumer. Either way, the rules are the same: only the producer side closes, everything buffered before close is still delivered, and anything blocked waiting to receive must wake up.

Many producers, many consumers

The pattern scales to M producers and N consumers on one buffer without code changes, which is why it is the basis of worker pools. What you lose is ordering: with several consumers, item 2 may finish before item 1. If order matters per key (all events for one account in order), partition: hash the key to one of N buffers, each with exactly one consumer. Kafka partitions apply exactly this rule across machines.

Batching

A consumer that takes one item at a time pays the per-item overhead (a lock, a syscall, a network round trip) every time. Draining everything available, up to a batch size, amortises that overhead and gets more efficient under load, which is the opposite of what most systems do. Java's drainTo and Go's "receive one, then non-blocking receive the rest" idiom both do this.

Reviewing agent-written code

Agents produce two typical shapes. One is an unbounded queue (queue.Queue() with no maxsize, an unbuffered list, new LinkedBlockingQueue<>()) that works in tests and fails under load. The other is a hand-rolled buffer with if instead of while around wait, or a busy loop that polls len(queue) with a sleep. Ask for the platform's blocking queue with an explicit capacity, a stated overflow policy, and a shutdown path, and for tests that cover a full buffer and close with waiters.

The same shape elsewhere

This is the overload problem shape solved with backpressure. A Unix pipeline zcat logs.gz | grep ERROR | sort runs three processes connected by kernel pipe buffers; when sort is slow, grep blocks on write and zcat blocks behind it. TCP's receive window is a producer-consumer buffer across a network: when the receiving application stops reading, the window shrinks to zero and the sender stops. A restaurant kitchen's ticket rail, with a limited number of clips, is the same buffer with humans on both sides.

Down the stack

Layer 1The contract

A bounded buffer of capacity C promises: it never holds more than C items; every item put is taken exactly once (unless dropped by a stated policy); a single producer's items are taken in the order they were put; a blocked producer resumes once there is room and a blocked consumer resumes once there is an item; and after close, buffered items are still delivered, then every receive reports "closed" instead of blocking. A good implementation also promises fairness among waiters: the producer that blocked first is the first to get room.

Layer 2The mechanism

Go's channel is a struct called hchan with a ring buffer (buf, dataqsiz, qcount, sendx, recvx), a closed flag, a lock, and two FIFO wait queues, recvq and sendq, of parked goroutines. A send first checks recvq: if a receiver is waiting, the value is copied directly to that receiver and it is made runnable, bypassing the buffer. Otherwise, if there is room, the value goes into buf[sendx]. Otherwise the sender parks on sendq with its value. A receive that takes from a full buffer also moves the first parked sender's value into the freed slot and wakes it. An unbuffered channel (capacity 0) is a pure rendezvous. Java's ArrayBlockingQueue uses one ReentrantLock with notEmpty and notFull conditions; LinkedBlockingQueue uses separate put and take locks so producers and consumers do not contend. Python's queue.Queue is a deque with a lock and three conditions, plus task_done and join to wait until consumers have processed everything. The LMAX Disruptor drops locks altogether: a preallocated ring of slots, a producer sequence claimed with compare-and-set, and consumers that track their own sequence and spin or wait until the producer's sequence passes it.

Layer 3The machine

When a consumer blocks on an empty queue, the runtime eventually parks the thread with a futex wait, and the producer's notify becomes a futex wake syscall, around 1 us, plus a context switch of a few microseconds when the woken thread runs. Go avoids the OS for goroutines: parking and waking is a scheduler operation of roughly 100 to 200 ns, and an uncontended buffered channel send or receive is tens of nanoseconds. Under contention the cost is in the cache: the head and tail indexes and the lock word bounce between cores, each transfer around 50 to 100 ns, so a single shared queue tops out at a few million operations per second. The Disruptor pads its sequence counters to separate 64-byte cache lines to stop false sharing and reaches tens of millions of events per second on one machine. The kernel has the same structure: a Linux pipe is a ring of 16 pages, 64 KiB by default, adjustable with fcntl(F_SETPIPE_SZ), and a writer to a full pipe sleeps until the reader drains it.

Why it's built this way

  1. Producers and consumers run at different and varying speeds, so a direct call couples the fast side to the slow one; a buffer decouples them.
  2. Memory is finite and queues hide latency, so the buffer is bounded and a full buffer pushes back on producers.
  3. Waiting by polling burns CPU, so blocked threads sleep on a condition and are woken by the other side.
  4. Waking is racy and can be spurious, so waiters recheck the condition in a loop, and runtimes hand values directly to parked receivers to skip the buffer and a wake-up.
  5. Shared indexes and locks become the bottleneck at high rates, so fast implementations partition the queue, batch, or use lock-free rings with padded counters.

Diagram

sequenceDiagram
  participant P as Producer
  participant B as Buffer (cap 2)
  participant C as Consumer
  C->>B: recv()
  Note over C,B: empty: consumer parks
  P->>B: send(a)
  B-->>C: hand a directly
  P->>B: send(b)
  P->>B: send(c)
  Note over P,B: buffered b, c (full)
  P->>B: send(d)
  Note over P,B: full: producer parks with d
  C->>B: recv() gets b
  B-->>P: d moved into buffer, producer wakes
  P->>B: close()
  C->>B: recv() gets c, then d, then closed

Worked example

The log shipper's numbers: the store accepts batches of up to 1,000 lines with a 40 ms round trip, so one consumer sends at most 25,000 lines per second; the production steady state is 3,000 lines per second with deploy bursts of 50,000 lines over about 2 seconds.

With a buffer of 100,000 lines (about 20 MB at 200 bytes per line), a burst of 100,000 lines arrives while the consumer drains 25,000 per second, so the buffer peaks around 50,000 and empties within 4 seconds. No producer blocks. During a 10-minute store outage, the consumer stops draining, the buffer fills in about 30 seconds at the steady rate, and the reader then blocks on put. Because the reader tracks its file offset, it resumes where it stopped when the store recovers; with the buffer full, it catches up at 25,000 minus 3,000, about 22,000 lines per second, clearing a 10-minute backlog of 1.8 million lines in under 90 seconds.

An earlier version used an unbounded queue. During the same outage it grew to 1.8 million lines, about 360 MB, and the container, limited to 256 MB, was killed by the OOM killer, losing the in-memory lines. The bound changed the failure from "crash and lose data" to "pause and catch up".

Batching mattered as much: sending one line per request at 40 ms each caps the consumer at 25 lines per second. Draining up to 1,000 available lines per request gives the 1,000-times improvement above.

Trade-offs

OptionStrengthsWeaknessesChoose when
Direct call, no bufferSimplest; no extra latencyFast side waits on slow side; bursts hurtSteady, matched rates
Unbounded queueNever blocks producersHides overload until memory runs outAlmost never; only with an external bound
Bounded, blockingNatural backpressure; no lossProducers stall; possible deadlock if they hold locksProducers that can wait (files, batch jobs)
Bounded, fail or dropProducers never stallData loss or errors under loadRequest handlers, telemetry, live media
Rendezvous (capacity 0)Tight coupling; each send confirms a receiveNo burst absorptionSynchronising handoffs, signalling
Lock-free ring (Disruptor)Very high throughput, low latencyComplex; fixed size; spin waiting costs CPUTrading, telemetry pipelines at millions per second

Pitfalls in production

Leadership lens

Check yourself

  1. Why must a condition wait be inside a while loop rather than an if?

    Show answer

    Wakeups can be spurious, and even after a real notify another thread may have changed the state before this one reacquired the lock, so the condition must be checked again.

  2. A Go channel has a parked receiver and an empty buffer when a send happens. Where does the value go?

    Show answer

    Directly to the waiting receiver, which is made runnable; the buffer is bypassed, saving a copy and keeping FIFO fairness.

  3. You add a second consumer to speed up event processing and customers see account events applied out of order. What do you change?

    Show answer

    Partition by account: hash the account ID to one of N queues, each with a single consumer, so each account's events stay in order while different accounts run in parallel.

  4. How do you choose a buffer's capacity?

    Show answer

    From the burst you need to absorb and the latency you can accept: capacity is roughly burst size minus what consumers drain during the burst, and capacity divided by drain rate must stay under the latency budget.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →