the producer-consumer problem
Picture a busy bakery. In the back, a baker keeps pulling fresh loaves out of the oven and putting them on a shelf. Out front, customers keep taking loaves off that shelf. The two of them work at their own speeds and never have to look at each other — the shelf in the middle lets them cooperate without coordinating every move. But the shelf has only so much room. If the baker is fast and the shelf fills up, the baker has to pause. If customers are fast and the shelf empties, they have to wait. The producer-consumer problem is the computing version of exactly this: one or more threads that create work and one or more that consume it, handing items back and forth through a shared buffer.
Concretely, producers add items to a shared queue and consumers remove them. The classic correct solution uses three pieces of synchronization. A counting semaphore called empty starts at the buffer size and counts free slots; a producer does wait(empty) before inserting, which blocks it if the buffer is full. A second counting semaphore called full starts at zero and counts filled slots; a consumer does wait(full) before removing, which blocks it if the buffer is empty. A producer signals full after inserting (waking a waiting consumer) and a consumer signals empty after removing (waking a waiting producer). A third lock, a mutex, guards the actual insert and remove so two threads never corrupt the queue's internal pointers at once. The order matters: take the counting semaphore first, then the mutex — reverse them and you can deadlock.
This pattern is everywhere once you learn to see it: a web server's accept thread handing connections to worker threads, a logging system buffering messages before a writer flushes them to disk, the pipe between two Unix commands, a video player decoding frames ahead of the screen that displays them. It is the standard way to decouple a fast stage from a slow one so neither has to know about the other's timing. The deep lesson is that a well-chosen shared buffer plus correct blocking turns a hard timing problem into a tidy, reusable design.
Producer loop: wait(empty); wait(mutex); buffer.put(item); signal(mutex); signal(full). Consumer loop: wait(full); wait(mutex); item = buffer.take(); signal(mutex); signal(empty).
The textbook semaphore solution. Note the two counting semaphores (empty, full) sit OUTSIDE the mutex; swapping that nesting risks deadlock.
Using a single shared counter and 'if (count == 0) wait' instead of a semaphore is the classic bug: between testing the count and sleeping, another thread can change it (a lost-wakeup race). Use the counting semaphores, or a condition variable inside a loop.