JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Producer–Consumer: Putting It Together

You now own three tools — the mutex, the condition variable, and the semaphore. This guide is where they finally work as a team: a bounded buffer between threads that make things and threads that use them, the single pattern that underlies almost every real concurrent system you will ever build.

The shape of the problem

Almost every concurrent program you will ever read has the same skeleton hiding inside it. One set of threads makes work; another set consumes it; and between them sits a shared buffer that the makers fill and the users drain. A web server has threads accepting connections and threads handling them. A logging system has threads that emit lines and one thread that writes them to disk. A video pipeline has a thread decoding frames and a thread drawing them. Strip away the costumes and they are all the same play, called the producer–consumer problem — and once you can solve it cleanly, you can solve a hundred programs that look different on the surface.

The reason we put a buffer in the middle at all is decoupling. Producers and consumers almost never run at exactly the same speed, and we do not want either side waiting on the other moment by moment. A buffer lets a fast producer surge ahead and stash a few items while the consumer catches up; it lets a fast consumer drain the backlog when the producer pauses. The buffer absorbs the bumps. But it cannot be infinite — memory is finite — so it has a fixed capacity, say room for N items. That single fact, bounded capacity, is what makes the problem interesting, because now there are two ways to get stuck, not one.

Spell out the two ways to get stuck, because the whole solution is built to handle exactly these. First, the buffer can be full: a producer with a new item has nowhere to put it, so it must wait until a slot frees up. Second, the buffer can be empty: a consumer with nothing to take must wait until an item appears. And on top of these two waiting rules sits the danger you already know — the buffer is shared mutable state, so every push and pop is a critical section that two threads must never run at the same time. Three obligations, then: mutual exclusion on the buffer, block-when-full, and block-when-empty. The art is satisfying all three at once.

Three tools, one job each

Here is the satisfying part: you already built every piece you need in the last three guides, and producer–consumer is just where they snap together. The first guide gave you the mutex for mutual exclusion — that handles obligation one, so two threads never corrupt the buffer's internals. The second gave you the condition variable and the wait-while-predicate pattern — that handles obligations two and three, the blocking-until-a-condition-holds. The third gave you the semaphore as a way to count a resource. There are two clean ways to assemble these, and seeing both is what makes the pattern stick.

The condition-variable assembly uses one mutex plus two condition variables. The mutex protects the buffer. One condition variable, call it `not_full`, is what producers wait on when the buffer is full; the other, `not_empty`, is what consumers wait on when it is empty. A producer that adds an item signals `not_empty` to wake a sleeping consumer; a consumer that removes an item signals `not_full` to wake a sleeping producer. Each side waits on the condition it needs and wakes the condition the other side is waiting for. That symmetry is the heart of it.

The semaphore assembly tells the same story with different nouns, and many people find it the more elegant of the two. Use two counting semaphores: `empty`, initialized to N (the number of free slots), and `full`, initialized to 0 (the number of filled slots). A producer does `wait(empty)` — consuming one free slot, blocking automatically if there are none — then puts its item in, then does `post(full)` — announcing one more filled slot. A consumer mirrors it exactly: `wait(full)`, take an item, `post(empty)`. The semaphores are the counts of slots, so blocking-when-full and blocking-when-empty fall out of the counting for free, with no explicit predicate to write.

Walking the bounded buffer, step by step

Let us watch the condition-variable version run, because tracing it once burns the pattern in. Picture a buffer with room for 3 items, currently holding 2. A producer wants to add a third. It locks the mutex, checks the buffer is not full (it has one slot), drops the item in, and signals `not_empty` in case a consumer was asleep. Now a second producer arrives wanting to add a fourth item: it locks the mutex, sees the buffer is full, and instead of spinning or failing it calls wait on `not_full` — which atomically releases the mutex and goes to sleep, so a consumer can get in.

  1. Producer: lock the mutex (enter the critical section that owns the buffer).
  2. Producer: WHILE the buffer is full, wait on not_full. Note 'while', not 'if' — you re-check the condition every time you wake, because of spurious wakeups and because another thread may have refilled the slot before you ran.
  3. Producer: now a slot is guaranteed free — insert the item, advance the tail index.
  4. Producer: signal not_empty (wake one consumer that may be waiting), then unlock the mutex.
  5. Consumer mirrors it: lock; WHILE empty wait on not_empty; remove an item; signal not_full; unlock.
/* condition-variable bounded buffer: the two halves are mirror images */

void produce(item x) {
    pthread_mutex_lock(&m);
    while (count == N)                 /* WHILE, never IF */
        pthread_cond_wait(&not_full, &m);
    buf[tail] = x;
    tail = (tail + 1) % N;
    count++;
    pthread_cond_signal(&not_empty);  /* a consumer may be asleep */
    pthread_mutex_unlock(&m);
}

item consume(void) {
    pthread_mutex_lock(&m);
    while (count == 0)                 /* WHILE, never IF */
        pthread_cond_wait(&not_empty, &m);
    item x = buf[head];
    head = (head + 1) % N;
    count--;
    pthread_cond_signal(&not_full);   /* a producer may be asleep */
    pthread_mutex_unlock(&m);
    return x;
}
Producer and consumer are near-perfect mirrors: each waits on its own condition while the buffer blocks it, and signals the other's condition after changing the count.

Trace the wake-up to feel why the `while` loop is not optional. When a consumer removes an item and signals `not_full`, the sleeping producer wakes — but it does not run instantly; it first has to re-acquire the mutex, and in the gap another producer might have rushed in and refilled the slot. If our woken producer trusted the signal blindly and inserted, it would overflow the buffer. So it loops back and re-tests `count == N`, and only proceeds when the slot is genuinely there. This is the wait-while-predicate discipline from the condition-variable guide, and it is also your defence against a spurious wakeup, where the OS wakes a waiter for no reason at all. Treat the signal as a hint to re-check, never as a promise.

The traps that bite real systems

Now the honest part, because producer–consumer looks tidy on the page and breaks in nasty, intermittent ways in practice. The first trap is the lost wakeup. Suppose you carelessly signal `not_empty` without holding the mutex, at the exact moment a consumer has tested `count == 0` but has not yet gone to sleep. The signal fires into an empty room — no one is waiting yet — and is gone. A heartbeat later the consumer sleeps, but the wakeup it needed already came and went, so it sleeps forever even though an item is sitting in the buffer. The cure is exactly why we hold the mutex across the check-and-wait: it makes 'see the condition' and 'go to sleep' one indivisible step that no signal can slip between.

The second trap is deadlock, and producer–consumer is one of the classic places it appears — which is exactly why the next and final guide of this rung is devoted to it. The simplest way to deadlock here is to wait while still holding a lock that the only thread who could wake you needs. Imagine a producer that locks the mutex, finds the buffer full, and then sleeps on `not_full` without releasing the mutex. No consumer can ever lock the mutex to remove an item, so no one will ever signal `not_full`, so the producer sleeps forever and the whole system freezes. The reason our code is safe is precisely that pthread_cond_wait() atomically releases the mutex as it sleeps — the one detail beginners most often get wrong when they roll their own.

Why this pattern is everywhere

Step back and notice what you have actually built: a thread-safe queue. That is all producer–consumer really is — a queue you can push to and pop from across thread boundaries, where push blocks when full and pop blocks when empty. This object goes by many names — a blocking queue, a channel, a pipe — and it is the backbone of how serious concurrent software is organized. Instead of letting threads reach into each other's memory and fight over locks everywhere, you let them communicate only by handing items through these queues. The dangerous shared mutable state shrinks down to one well-guarded buffer that you got right once.

This idea scales straight up the abstraction stack. The buffer-between-producer-and-consumer you wrote with a mutex and condition variables is the same shape as a shell pipeline `producer | consumer`, where the kernel's pipe is the bounded buffer and the OS handles the blocking for you. It is the same shape as a thread pool, where worker threads consume tasks that other threads produce onto a shared work queue. Languages built for concurrency, like Go, hand you this object directly as a channel and quietly run the mutex-and-condition-variable machinery underneath. Learn it once at this level and you recognize it forever, at every level above.

So carry this forward as the reward for the last three guides: the mutex gave you safety, the condition variable gave you the ability to wait for a state, the semaphore gave you counting, and producer–consumer is where those three become a single reusable thing. You have not just learned a problem; you have learned the unit that real concurrent systems are assembled from. The last danger left is the one we kept brushing against — what happens when waiting goes wrong and threads block each other in a cycle forever — and that is deadlock, the subject of the final guide in this rung.