A bakery, a shelf, and two speeds
In the last rung you learned the danger of sharing (the race condition) and the family of tools that tame it: mutual exclusion, the lock, the semaphore, and the monitor. Those were the grammar of concurrency. Now we read whole sentences — the classic problems every systems programmer learns by heart, the ones that show what those tools are actually for. The first and most important is the producer-consumer problem, and the picture is a bakery. In the back, a baker keeps pulling fresh loaves from the oven and setting them on a shelf. Out front, customers keep taking loaves off that shelf. Neither watches the other; the shelf in the middle lets them cooperate without coordinating every single move.
This decoupling is the whole point. The baker is the producer: it makes items and puts them somewhere. The customers are the consumers: they take items and use them. The reason to separate them is that they run at different, ever-changing speeds. The baker can pull out a tray of twelve at once; the next customer might dawdle. If they had to hand each loaf over directly, the faster one would constantly stand idle waiting for the slower one. The shelf absorbs the mismatch, so each side can surge ahead and fall behind without the other ever noticing — as long as the shelf itself is managed correctly.
The shelf has a size: the bounded buffer
The shelf in the middle is the bounded buffer, and the word 'bounded' is the one that matters. The shelf holds at most N loaves — never more. You might wonder why we do not just use an endlessly growing pile (an unbounded buffer). The bound is a feature, not an apology. If producers can outrun consumers forever and the buffer grows without limit, your program eventually devours all of memory and is killed. A fixed size guarantees that cannot happen: when the shelf fills, the producer is made to wait. The bound applies back-pressure, gently forcing a runaway producer to slow to the rate the consumers can sustain.
In code the bounded buffer is usually a circular (ring) buffer: a fixed array of N slots with two indices, an 'in' position where the next item is written and an 'out' position where the next item is read, each advancing and wrapping around the end of the array back to the start. The buffer is empty when 'in' equals 'out' and full when advancing 'in' would crash into 'out'. But here is the honest caution: on its own this is just an array and two integers — it is NOT thread-safe. It becomes a correct concurrent component only when we wrap it in synchronization. The data structure is the easy part; the coordination is the whole problem.
What exactly must the synchronization protect? Think in terms of a concurrency invariant — a promise about the data that must always hold true. Here the invariant is simple: the count of items on the shelf stays between 0 and N, and it equals the number of items actually stored. A producer breaks this promise for an instant while it writes a slot and bumps the count; a consumer breaks it while it reads a slot and lowers the count. Our entire job is to make sure no other thread can ever peek during those instants, and that nobody ever tries to add to a full shelf or take from an empty one.
Building the solution one semaphore at a time
The classic correct solution uses three pieces of synchronization, and the lovely thing is that each one answers one obvious question. First question: how does a producer know there is room? We use a counting semaphore called 'empty' that starts at N and counts free slots. Recall from the last rung that a counting semaphore is just an integer with two atomic operations — wait(S) decrements it, blocking the caller if it would go below zero, and signal(S) increments it, waking one waiter. So a producer does wait(empty) before inserting: if the shelf is full (empty has hit 0), the producer simply blocks, exactly the back-pressure we wanted. No busy-waiting, no spinning — it sleeps until a slot frees up.
Second question: how does a consumer know there is something to take? We mirror the trick with a second counting semaphore called 'full' that starts at 0 and counts filled slots. A consumer does wait(full) before removing: if the shelf is empty (full is 0), the consumer blocks until a producer signals. And the two semaphores hand off to each other beautifully. After a producer inserts, it does signal(full) — telling consumers 'there is now one more thing to eat'. After a consumer removes, it does signal(empty) — telling producers 'there is now one more free slot'. The two counters always sum to N, and they make each side wait for exactly the right condition.
Third question — and this is the one beginners forget: even when there IS room and there ARE items, two producers might try to write the same slot at the same time, racing on the 'in' index and the count. The empty/full semaphores guarantee availability, but they do NOT guarantee that only one thread touches the buffer's internals at once. So we add a third piece: a mutex (a binary semaphore initialized to 1) that wraps the actual insert or remove, giving us mutual exclusion over the shelf's bookkeeping. Three tools, three jobs: 'empty' rations free space, 'full' rations available items, and the mutex protects the data structure itself.
PRODUCER CONSUMER
-------- --------
wait(empty) // need a slot wait(full) // need an item
wait(mutex) // lock the shelf wait(mutex) // lock the shelf
... put item into buffer ... ... take item from buffer ...
signal(mutex) // unlock signal(mutex) // unlock
signal(full) // +1 item ready signal(empty) // +1 free slot
empty starts at N (free slots)
full starts at 0 (filled slots)
mutex starts at 1 (the shelf lock)The order of the two waits is not a detail
Look hard at the order in that sketch, because swapping two lines turns a correct program into one that can freeze forever. The producer does wait(empty) and THEN wait(mutex). Suppose a careless programmer flips them: wait(mutex) first, then wait(empty). Now imagine the buffer is full. A producer grabs the mutex (locking the shelf), then calls wait(empty) — which blocks, because there are no free slots. It is now asleep while holding the lock. A consumer comes along wanting to remove an item and free a slot, but to do so it must first acquire the mutex... which the sleeping producer is holding and will never release. Each waits for the other forever. That is a textbook deadlock.
The lesson generalizes far beyond this one example: when you must hold more than one lock, the order in which you acquire them is load-bearing. The safe discipline here is 'count first, lock second' — secure the right to proceed (a free slot, or an available item) BEFORE you grab the exclusive lock on the shared structure, and never go to sleep while holding the mutex. If you take the mutex only once you already know you can finish, you will hold it for the shortest possible time and never block another thread out while you yourself are stuck waiting on something else.
The same idea, expressed more safely
The semaphore solution is elegant, but it is also fragile — and we should be honest about that. A semaphore protects the buffer only if EVERY producer and EVERY consumer performs exactly the right waits and signals in exactly the right order. Forget one signal(empty) and slots leak away until everything jams; flip two waits and you deadlock; signal the wrong semaphore and the invariant quietly rots. The semaphore is bare; the correctness lives entirely in the programmer's discipline, scattered across every thread. This is why higher-level languages and libraries usually offer the same pattern in a safer wrapper.
The higher-level expression uses a monitor with condition variables. The mutex becomes the monitor's automatic lock — entering the buffer's insert or remove method acquires it, leaving releases it, so you cannot forget. Where a producer would have blocked on wait(empty), it instead does wait on a condition variable like 'notFull' while inside the locked method; the condition variable atomically releases the lock and sleeps, then re-acquires the lock on waking. A consumer that removes an item calls signal on 'notFull' to wake a waiting producer; a producer that inserts calls signal on 'notEmpty'. The structure mirrors the semaphore solution exactly, but the lock is managed for you and the waiting is tied directly to the named conditions.
Why this one pattern is everywhere
Once you learn to see the producer-consumer shape, you find it hiding in nearly every concurrent system. A web server has an accept thread (the producer) that takes incoming connections and drops them into a queue, while a pool of worker threads (the consumers) pull connections out and handle them. A logging system buffers messages from many threads (producers) before one writer thread (the consumer) flushes them to disk in batches. A video player decodes frames ahead of time into a buffer, and the display reads them out at the right pace. Each is the same bakery: a fast stage and a slow stage decoupled by a bounded shelf.
The most familiar example of all is the Unix pipe. When you type a command like 'ls | grep txt', the shell connects 'ls' (the producer, writing file names) to 'grep' (the consumer, reading and filtering them) through a kernel buffer that is exactly a bounded buffer. If 'grep' is slow, the buffer fills and the kernel blocks 'ls' until there is room — back-pressure, automatic and invisible. You have been using a correctly-synchronized producer-consumer queue every time you wrote a pipe, without ever thinking about a single semaphore.
That is the real lesson of this guide, and the bridge into the rest of the rung. The producer-consumer problem is not one puzzle; it is a lens. It teaches you to spot any two parties cooperating at different speeds and to reach for a bounded buffer plus the right synchronization. Next we will meet two more classic puzzles — the readers-writers problem and the dining philosophers — that stretch these same tools in new directions and expose new ways they can go wrong, including the starvation and deadlock you must always design against.