Concurrency
The Producer-Consumer Problem
Coordinating threads over a shared bounded buffer — two semaphores plus a mutex
The producer-consumer problem is the classic concurrency problem of synchronizing two kinds of threads — producers that add items to a shared, fixed-size buffer and consumers that remove them — so that no item is lost, no empty slot is read, and neither side spins wastefully. The canonical solution, introduced by Edsger Dijkstra in 1965, uses two counting semaphores (empty and full) plus a binary mutex, giving O(1) enqueue and dequeue with threads that block instead of busy-wait. It is the foundation of every blocking queue, thread pool, and backpressure mechanism in modern systems.
- Enqueue / dequeue timeO(1)
- SpaceO(N) buffer
- Synchronization2 semaphores + 1 mutex
- Key invariantempty + full = N
- Formulated byE. W. Dijkstra, 1965
- PowersBlocking queues, thread pools, pipelines
Interactive visualization
Press play, or step through manually. The visualization is yours to drive — try it before reading on.
Watch the 60-second explainer
A condensed visual walkthrough — narrated, captioned, under a minute.
Why the producer-consumer problem matters
Almost every non-trivial concurrent program is, somewhere inside it, a producer-consumer pipeline. A web server has an acceptor thread producing connections and a pool of worker threads consuming them. A logging library produces log records and a background flusher consumes and writes them to disk. A video decoder produces raw frames and a renderer consumes them. Kafka, RabbitMQ, and every message queue are producer-consumer at industrial scale.
The reason it earns a dedicated name is that the naive version is subtly wrong. If you just share a buffer and a counter between threads, you hit three separate bugs: lost updates (two threads write the same slot), reading garbage (a consumer reads a slot the producer hasn't finished writing), and busy-waiting (threads burn 100% CPU spinning on "is there anything yet?"). Dijkstra's solution kills all three with the right primitives — and the details of which primitive guards what are exactly where beginners deadlock.
How it works — two semaphores and a mutex
The shared state is a bounded buffer of capacity N — in practice a circular array with head and tail indices taken modulo N. Three synchronization objects coordinate access:
empty— a counting semaphore initialized toN. It counts the number of free slots. A producer must acquire one before inserting.full— a counting semaphore initialized to0. It counts the number of filled slots. A consumer must acquire one before removing.mutex— a binary semaphore initialized to1. It makes the actual buffer update (moving an index, touching a slot) atomic when there are multiple producers or consumers.
A semaphore is a counter with two atomic operations: wait (Dijkstra's P, "proberen") decrements it, blocking the calling thread if it would go negative; signal (V, "verhogen") increments it, waking one blocked thread if any wait. The crucial property is that a semaphore remembers signals: a signal issued while no one waits is not lost — it raises the count.
The producer's body is:
wait(empty) // block if buffer is full (no free slots)
wait(mutex) // enter critical section
buffer[tail] = item
tail = (tail + 1) % N
signal(mutex) // leave critical section
signal(full) // announce one more filled slot
The consumer's body is the mirror image:
wait(full) // block if buffer is empty (no filled slots)
wait(mutex) // enter critical section
item = buffer[head]
head = (head + 1) % N
signal(mutex) // leave critical section
signal(empty) // announce one more free slot
The invariant that makes it correct is empty + full = N at every quiescent point: every slot is either free or filled, never both, never neither. Because a producer decrements empty and later increments full (and the consumer does the reverse), the two counters trade a token back and forth without ever exceeding N or dropping below 0.
The ordering rule — and the deadlock if you break it
The single most important detail: acquire the counting semaphore before the mutex, and release the mutex before signaling the other counting semaphore. Reverse the first pair and you get a textbook deadlock.
Suppose a producer wrote wait(mutex) first, then wait(empty). On a full buffer, wait(empty) blocks — but the producer is still holding the mutex. The only thread that can create a free slot is a consumer, and it needs the mutex to remove an item. Neither can proceed: circular wait, no progress, dead. Order matters, and the order is non-negotiable.
Semaphores vs. condition variables
The other canonical solution replaces the two semaphores with a condition variable (or two) guarded by a mutex — a monitor. The two approaches solve the same problem with a different memory model.
| Two semaphores + mutex | Mutex + condition variables | |
|---|---|---|
| Signal has memory? | Yes — count persists if no one waits | No — notify is lost if no one waits |
| Wait pattern | wait(sem) — count guards it | while (!predicate) cond.wait(mutex) |
| Spurious wakeups | Not applicable | Must re-check predicate in a while loop |
| Complex predicates | Awkward (only integer counts) | Natural (any boolean over shared state) |
| Enqueue / dequeue time | O(1) | O(1) |
| Typical API | POSIX sem_wait/sem_post | Java ArrayBlockingQueue, C++ std::condition_variable |
The condition-variable version must re-check the predicate in a while loop, not an if. A condition variable can wake spuriously, and — more importantly — between the notify and the woken thread reacquiring the mutex, another thread may have already consumed the slot. The while re-test closes both gaps.
How it works in Python
A faithful implementation of the two-semaphore, one-mutex solution over a circular buffer. The blocking is done by the semaphores — no thread ever spins.
import threading
N = 5
buffer = [None] * N
head = 0 # next slot to consume
tail = 0 # next slot to produce
empty = threading.Semaphore(N) # free slots, starts full
full = threading.Semaphore(0) # filled slots, starts empty
mutex = threading.Lock() # protects head/tail/buffer
def producer(items):
global tail
for item in items:
empty.acquire() # P(empty): block if buffer full
with mutex: # critical section
buffer[tail] = item
tail = (tail + 1) % N
full.release() # V(full): one more item available
def consumer(count):
global head
for _ in range(count):
full.acquire() # P(full): block if buffer empty
with mutex: # critical section
item = buffer[head]
buffer[head] = None
head = (head + 1) % N
empty.release() # V(empty): one more free slot
handle(item)
def handle(item):
print("consumed", item)
# One producer, one consumer, three items:
p = threading.Thread(target=producer, args=([1, 2, 3],))
c = threading.Thread(target=consumer, args=(3,))
p.start(); c.start(); p.join(); c.join()
Note that empty.acquire() is called before with mutex, exactly as the ordering rule demands. In real Python code you would usually just use queue.Queue(maxsize=N), which is precisely this bounded blocking queue wrapped in a friendly API — put() blocks when full, get() blocks when empty.
Backpressure — the bounded buffer's superpower
The bound N is not just about memory frugality; it is the mechanism for backpressure. When a producer outruns a consumer, the buffer fills, wait(empty) blocks the producer, and the producer's effective rate is pinned to the consumer's rate. The system self-regulates.
Remove the bound — use an unbounded queue — and you remove backpressure. A fast producer paired with a slow consumer will grow the queue without limit until the process is killed by the out-of-memory killer. This failure mode is common enough that reactive-streams specifications (RxJava, Project Reactor, Akka Streams) build backpressure protocols directly into their APIs. The lesson is old: a bounded buffer is a feature, not a limitation.
Common misconceptions and pitfalls
- Locking the mutex before the resource semaphore. The deadlock described above. Always
wait(empty)/wait(full)first,wait(mutex)second. - Signaling
fulloremptywhile still holding the mutex. Correct but suboptimal — you wake a thread that immediately blocks trying to get the mutex you still hold. Release the mutex first, then signal. - Using
ifinstead ofwhilewith condition variables. Spurious and stolen wakeups mean the predicate can be false even afterwaitreturns. Re-check in a loop. - Dropping the mutex for a single producer and single consumer. Sometimes safe (the two never touch the same index), but only if head/tail updates are individually atomic and there is a memory barrier. Add the mutex unless you have proven you can remove it.
- Confusing this with a spinlock. A spinlock busy-waits and burns CPU; the producer-consumer solution blocks, yielding the CPU to other work. For a buffer that is often empty or full, blocking is dramatically cheaper.
- Forgetting the poison pill for shutdown. Consumers blocked on
wait(full)never wake on their own. To stop them cleanly, producers enqueue a sentinel ("poison pill") item that tells each consumer to exit.
History — Dijkstra, 1965
Edsger W. Dijkstra introduced both the semaphore and the bounded-buffer producer-consumer problem around 1965, in the same run of work that produced the dining philosophers and the sleeping barber. The letters P and V — for the Dutch proberen (to test) and verhogen (to increment) — are still the standard names for semaphore wait and signal in operating-systems textbooks. The problem appeared as a worked example in his notes on Cooperating Sequential Processes and underpinned the THE multiprogramming system he built at the Eindhoven University of Technology. Sixty years later, the two-semaphore-plus-mutex pattern is unchanged — a rare piece of computer science that got it exactly right the first time.
Frequently asked questions
Why do you need two semaphores instead of one?
The two counting semaphores track two independent resources. empty starts at N (the buffer capacity) and counts free slots; full starts at 0 and counts filled slots. A producer waits on empty (block if the buffer is full) and signals full after inserting. A consumer waits on full (block if the buffer is empty) and signals empty after removing. One semaphore could only enforce one of the two limits — you'd either overwrite unread items or read empty slots. The two together enforce the invariant empty + full = N at all times.
What is the mutex for if the semaphores already synchronize?
The empty and full semaphores only bound how many producers or consumers may be inside the buffer; they do not make the buffer update atomic. With two or more producers, the actual insert — advancing the tail index and writing the slot — is a critical section. Two producers that both passed wait(empty) could interleave and corrupt the tail pointer or clobber the same slot. The mutex (a binary semaphore initialized to 1) serializes the buffer access itself. With exactly one producer and one consumer you can sometimes omit it, but the general N-producer M-consumer case requires it.
Why must you lock the mutex inside the semaphore wait, not outside?
You must call wait(empty) before wait(mutex), never the reverse. If a producer grabbed the mutex first and then blocked on a full buffer's empty semaphore, it would sleep while still holding the mutex — the consumer that needs the mutex to make room can never run, and the system deadlocks. Always acquire the counting (resource) semaphore first and the mutex second, then release the mutex before signaling the other counting semaphore.
How is this different from solving it with condition variables?
Semaphores carry their own count, so a signal is remembered even if no one is waiting. Condition variables have no memory — a notify sent when no thread is waiting is lost. That is why every condition-variable wait must sit inside a while loop that re-checks the predicate (buffer full or empty) under the same mutex, guarding against lost wakeups and spurious wakeups. Condition variables plus a mutex form a monitor and are more flexible for complex predicates; semaphores are more compact for this exact problem.
What is backpressure and how does the bounded buffer provide it?
Backpressure is a feedback signal that slows a fast producer when a slow consumer falls behind. Because the buffer has a fixed capacity N, once it fills, wait(empty) blocks the producer until the consumer frees a slot. The producer's rate is therefore automatically throttled to the consumer's rate — no unbounded memory growth, no out-of-memory crash. An unbounded queue removes backpressure and lets a fast producer exhaust memory, which is why production systems almost always use bounded blocking queues.
Who invented the producer-consumer problem?
Edsger W. Dijkstra formulated the bounded-buffer producer-consumer problem and the semaphore primitive that solves it around 1965, as part of the same body of work (the THE multiprogramming system and his 'Cooperating Sequential Processes' notes) that gave us the dining philosophers and the sleeping barber. The P (wait/proberen) and V (signal/verhogen) operations on semaphores come directly from this work.
What is the time complexity of enqueue and dequeue in a bounded buffer?
Both are O(1) time. A circular (ring) buffer stores items in a fixed array with head and tail indices taken modulo N, so inserting or removing is a constant-number-of-operations update plus the semaphore and mutex calls, which are themselves O(1) amortized. Space is O(N) for the buffer of capacity N. The blocking is not counted as work — a blocked thread consumes no CPU, unlike a spinlock, which busy-waits.