Why a semaphore was not quite enough
By the end of the last guide the semaphore looked almost magical: one integer, two atomic operations, and from those you can build a mutex, a resource counter, and an event signal. But you also saw its sharp edge — it protects shared data only if every thread agrees to call wait before touching the data and signal after, every single time. Forget one signal and a buzzer leaves the tray forever; swap a wait and a signal by accident and two threads quietly slip into a one-at-a-time critical section with no error message at all. The correctness lives entirely in your discipline, scattered across the whole program, where one tired afternoon can break it.
There is also a deeper awkwardness, not just a clumsiness. A semaphore counts; it does not naturally express "wait until a condition about my own data becomes true." Think of the producer who must wait until the buffer is not full, or a bank account that must wait until the balance is at least the amount being withdrawn. With raw semaphores you have to encode each such condition as a separate counter and remember to nudge it from exactly the right places — empty here, full there — and the meaning of the rule ends up smeared across many wait and signal calls rather than written down in one spot. What we really want is to say, in plain words, "hold here until this is true," and have the machinery handle the bookkeeping.
The monitor: a room that locks its own door
A monitor answers the discipline problem by building the lock into the structure of the code itself. Picture a small room that holds some shared data, and the rule of the room is simple: only one person may be inside at a time. You do not lock and unlock the door by hand; the room locks itself the moment you step in and unlocks itself the moment you step out. In programming terms, a monitor bundles the shared variables together with the procedures that operate on them, and the language guarantees that only one thread can be executing inside any of those procedures at once. The mutual-exclusion lock is acquired and released automatically at the door — you cannot forget it, because you never wrote it.
This is the same mutual exclusion you already know, but moved from a convention into the wall of the room. Where a semaphore is a sharp tool you must remember to hold correctly, a monitor is a safe shape you simply work inside of. Java's synchronized methods, C++'s lock-guarded objects, and the monitors of older languages like Mesa and Modula all descend from this idea, first described by Tony Hoare and Per Brinch Hansen. The win is enormous in practice: the most common synchronization bug — touching shared data without taking the lock — becomes nearly impossible, because there is no way to be inside the room without the door having locked behind you.
The condition variable: a waiting room inside the room
Mutual exclusion alone is not enough, because of a problem we just named: sometimes a thread gets inside the room, looks at the data, and discovers it cannot proceed yet — the buffer is empty, the balance is too low. It must wait. But if it simply spins or sleeps while still holding the door locked, nobody else can ever get in to change the very thing it is waiting for. That is a self-made standstill. The room needs a way for a thread to step aside, release the door so others can enter and make progress, and be woken up later when the situation may have changed.
That mechanism is the condition variable. It is not a counter and stores no value — think of it as a named waiting room attached to the monitor, plus two operations. A condition variable offers wait and signal as well, but they mean something different from the semaphore's. Calling wait on a condition variable does three things as one indivisible step: it puts the calling thread to sleep, it atomically releases the monitor's lock so others can enter, and it adds the thread to that condition's waiting room. Calling signal wakes one thread that is sleeping on that condition — and if nobody is waiting, signal simply does nothing and is forgotten. That last detail is the sharpest break from a semaphore, whose signal is remembered as a permanent bump to the count.
Watching it work: the bounded buffer, rewritten
Let us walk the producer-consumer problem one more time, now expressed as a monitor instead of three bare semaphores. The shared buffer and its count live inside the monitor; we add two condition variables, notFull (where producers wait) and notEmpty (where consumers wait). A producer that finds the buffer full does not encode anything in a counter — it just waits on notFull. A consumer that empties a slot simply signals notFull on its way out, because it has made room. The rule of the room, the waiting, and the waking now read like the sentences you would say out loud.
monitor BoundedBuffer: # only one thread inside at a time
buffer[N], count = 0
condition notFull, notEmpty
procedure put(item):
while count == N: # WHILE, not if -- re-check on wakeup
wait(notFull) # sleeps AND releases the monitor lock
add item to buffer; count += 1
signal(notEmpty) # a consumer may now proceed
procedure take():
while count == 0:
wait(notEmpty)
remove item; count -= 1
signal(notFull) # a producer may now proceed
return item- A consumer calls take(), the door locks behind it, but count is 0 — the buffer is empty.
- It hits wait(notEmpty): in one atomic step it falls asleep, releases the monitor lock, and joins the notEmpty waiting room. The door is now free.
- A producer can now enter put(), add an item so count becomes 1, and call signal(notEmpty) before it leaves.
- The sleeping consumer is moved from the waiting room back toward the door; once it re-acquires the lock, its while loop re-checks count, sees it is no longer 0, and proceeds to take the item.
Honest corners: signal-and-wait, signal-and-continue, and what is left over
One subtlety decides everything about the while loop above. When a thread inside the monitor calls signal and wakes a sleeper, who runs next — the signaler, who still holds the lock, or the freshly woken thread, who now wants it back? Both cannot be inside at once. There are two classic answers. Under Hoare semantics (signal-and-wait), the signaler immediately yields and the woken thread runs at once, so the condition is guaranteed still true when it resumes. Under Mesa semantics (signal-and-continue), used by almost every real system today, the signaler keeps going and the woken thread merely becomes eligible to re-enter later. Because time passes before it actually gets back in, its condition might be false again — which is the whole reason real code must loop, not just check once.
A second honest detail: signal wakes only one waiter, but sometimes you genuinely want to wake them all — say a writer just finished and any number of waiting readers may now proceed in the readers-writers problem. For that there is broadcast (often called signalAll or notifyAll), which moves every thread in that waiting room back toward the door. They do not all run at once, of course — they file through the single locked door one by one, each re-checking its condition in its while loop, and any whose condition has since gone false simply waits again. Broadcast is the safe, slightly wasteful default when you are unsure exactly how many should wake.
It is worth being clear-eyed about what the monitor does and does not buy you. It dissolves the most common, silly mistakes — a forgotten unlock, data touched without the lock — because those are now structurally impossible inside the room. What it does not dissolve is the hard part of concurrency. You can still create a deadlock by taking two monitors' locks in opposite orders in different threads. You can still suffer priority inversion, where a low-priority thread holding the monitor stalls a high-priority one waiting at the door. And you must still think clearly about which condition to wait on and when to signal. The monitor moves the bookkeeping into the tool; it does not move the thinking out of your head.
Where this rung leaves you
Look back at the climb. The rung opened with a single shocking fact — that count++ is several non-atomic steps, so two threads interleaving can lose an update — and that gave the critical section and its three requirements: mutual exclusion, progress, and bounded waiting. You saw Peterson's software solution and why it needs memory barriers, then the hardware that makes it cheap (test-and-set, compare-and-swap), then locks built on that hardware: spinlocks versus blocking locks. From the lock you climbed to the semaphore, a single counter that guards, counts, and signals. And now, with the monitor and the condition variable, you have reached the layer where the discipline finally lives inside the structure rather than in your memory.
But notice the warning that kept recurring, the one this last guide ended on too: even with the best tool, two threads each holding one lock and each waiting for the other's freeze into a standstill that no amount of careful waiting will break. That frozen, everyone-waits-forever knot is the deadlock — and unlike a forgotten signal, no monitor can structurally prevent it. Understanding exactly what makes it possible, the four conditions that must all hold at once, and what real systems choose to do about it (often, honestly, nothing) is the whole subject of the next rung.