Skip to main content

Producer Consumer

complexitypopularity

Let producers put items on a shared queue and consumers take them off at their own pace, so a bounded buffer absorbs bursts and decouples how fast work arrives from how fast it gets handled.

The problem

Baker.bakeAndDeliver(driver) hands a fresh loaf straight to a driver and waits until the driver is ready to take it. That works for one loaf at a time, but the moment production outpaces delivery - a dozen loaves finishing while one driver is still on the road - there is nowhere for the extra loaves to go. The baker is stuck waiting on the driver's schedule, which has nothing to do with baking.

The two activities - producing and consuming - are running at genuinely different, often unpredictable, paces. Wiring them directly together means the slower one always sets the speed for both.

The solution

Put a bounded buffer between them. Producers add items to the queue and move on; consumers remove items from the queue whenever they are ready. Neither side calls the other directly, and neither side needs to know how many of the other kind exist, or how fast they are running.

The bound matters as much as the queue itself: a full queue makes the producer wait (backpressure), and an empty queue makes the consumer wait, cheaply, instead of spinning. The buffer is what turns "these two things run at different speeds" from a bug into a fact the system was designed to handle.

BakerBoundedQueue (cooling rack)DeliveryDriverbakeLoaf()1enqueue(loaf)2dequeue()3enqueue(loaf2) ... enqueue(loaf12)4capacity reached5dequeue()6enqueue(loaf11)7
  1. 1The baker works at their own pace, entirely unaware of whether a driver is nearby or three towns away.
  2. 2The finished loaf goes on the rack. The baker never hands it directly to a driver.
  3. 3When the driver is ready for the next load, they take a loaf off the rack - blocking cheaply if it happens to be empty.
  4. 4A dozen loaves finish in a rush. The rack absorbs the burst; the baker never has to wait for a driver to catch up.
  5. 5The rack has room for ten loaves, not twelve. Once full, the baker has to wait before setting down the eleventh.
  6. 6The driver clears space by taking loaves at their own pace, which is what lets the baker resume.
  7. 7With space freed, production resumes exactly where it left off - neither side ever spoke to the other directly.

Structure

Neither Baker nor DeliveryDriver has any relationship to the other in this diagram - both point only at BoundedQueue. That missing direct line is the whole point.

BoundedQueuecapacityenqueue(item)dequeue()SHAREDBakerbakeLoaf()run()PRODUCERDeliveryDriverloadTruck(loaf)run()CONSUMERLoafidbakedAtITEM
uses

Code

Same example three ways: a baker and a delivery driver decoupled by a bounded rack.

// The baker hands a loaf directly to a driver, so both are stuck in lockstep.
class Baker is
method bakeAndDeliver(driver) is
loaf = bakeLoaf()
driver.load(loaf) // blocks here until the driver is free to accept it
// A dozen loaves finishing in a rush means eleven of them wait on one
// driver's schedule, with nowhere to sit in the meantime.
// Neither side calls the other directly - both only ever touch the shared queue.
class BoundedQueue {
enqueue(item) { /* blocks while full: backpressure on the producer */ }
dequeue() { /* blocks while empty: no busy-polling on the consumer */ }
}
const rack = new BoundedQueue(10);
startThread(() => { while (true) rack.enqueue(bakeLoaf()); }); // baker
startThread(() => { while (true) loadTruck(rack.dequeue()); }); // driver
// A dozen loaves finishing in a rush just fill the rack; the baker only
// waits once it is genuinely full, not once per loaf.
// The only thing producers and consumers ever touch.
class BoundedQueue is
field items: queue
field capacity
field lock, notFull, notEmpty: condition
 
constructor BoundedQueue(capacity) is
this.capacity = capacity
 
method enqueue(item) is
lock.acquire()
while items.size() == capacity do
notFull.wait(lock) // backpressure: producer waits when full
items.push(item)
notEmpty.signal()
lock.release()
 
method dequeue() is
lock.acquire()
while items.isEmpty() do
notEmpty.wait(lock) // consumer waits when empty, instead of polling
item = items.pop()
notFull.signal()
lock.release()
return item
 
// Produces at its own pace. Never touches a consumer directly.
class Baker implements Thread is
field rack: BoundedQueue
 
method run() is
while true do
loaf = bakeLoaf()
rack.enqueue(loaf)
 
// Consumes at its own pace. Never touches a producer directly.
class DeliveryDriver implements Thread is
field rack: BoundedQueue
 
method run() is
while true do
loaf = rack.dequeue()
loadTruck(loaf)
 
// Wiring: both sides only know about the shared rack.
rack = new BoundedQueue(10)
baker = new Baker(rack)
driver = new DeliveryDriver(rack)
baker.start()
driver.start()

When to use it

  • Work is produced and consumed at genuinely different, often bursty rates, and wiring the two together directly means one side's schedule dictates the other's.
  • More than one producer or consumer may exist (or may need to exist later), and neither side should have to know how many of the other kind are currently running.

Pitfalls

  • Unbounded queues. Removing the size limit removes backpressure entirely - a slow consumer no longer causes the producer to wait, it causes memory to grow until something else breaks.
  • Losing items on shutdown. A queue that still holds items when the process exits needs an explicit drain-or-discard decision; silently losing in-flight items is rarely the right default for anything that matters.
  • Starvation from unfair queues. A queue implementation that lets some producers or consumers monopolize access can starve others; most real blocking queues guarantee FIFO fairness for exactly this reason.

Don't confuse it with

  • Thread Pool. A thread pool is usually Producer-Consumer with the consumer side constrained to a fixed, bounded set of worker threads; Producer-Consumer itself makes no claim about how many producers or consumers there are, or that consumers must be threads at all.
  • Observer. Observer's notify() is a synchronous push to every subscriber the instant it fires. Producer-Consumer's queue is what lets the consumer run on a completely different schedule than the producer - that decoupling in time is the entire reason the buffer exists.
  • Message queues / brokers (Kafka, SQS). Same idea, grown up: durable, often distributed, frequently with multiple independent consumer groups. The in-process version here is the same relationship at a smaller scale.

Check yourself

Question 1 of 5

What does a bounded queue do when a producer tries to add an item while it is full?