Module 05 · Synchronization · Lesson 4 of 6
Monitors and condition variables
How threads wait for a condition without spinning: condition variables paired with a lock, the wait-in-a-loop rule, spurious wakeups, signal versus broadcast, and a bounded buffer built from them. Plus monitors and their language forms.
Why it matters
Locks keep threads out of each other's way. Many problems also need threads to wait for each other: a consumer waits until a queue has items, a producer waits until it has space, a worker waits until shutdown or new work. Polling a flag in a loop burns CPU; sleeping a fixed time adds latency. Condition variables are the primitive under every blocking queue, thread pool and future, and their rules are where subtle hangs and lost wakeups come from.
The idea
A condition variable lets a thread sleep until another thread tells it that something it cares about may have changed. It is always used with a mutex that protects the shared state the condition is about. Its operations:
- 1
wait(mutex)
Atomically release the mutex and put the thread to sleep; when woken, reacquire the mutex before returning. The atomic release-and-sleep is the crucial part: it closes the window in which a signal could be sent after the thread checked the condition but before it slept, the lost wakeup. - 2
signal (notify)
Wake one waiting thread, if any. - 3
broadcast (notifyAll)
Wake every waiting thread; each re-checks its condition.
Always wait in a loop
A woken thread must re-check its condition, because the condition may no longer hold by the time it reacquires the mutex (another thread got there first), and because implementations permit spurious wakeups with no signal at all. The correct shape is always: lock; while the condition is false, wait; act; unlock. Using if instead of while is the single most common condition-variable bug.
The bounded buffer
The classic producer-consumer queue uses one mutex and two condition variables. To put: lock; while the buffer is full, wait on notFull; add the item; signal notEmpty; unlock. To take: lock; while the buffer is empty, wait on notEmpty; remove an item; signal notFull; unlock. This is exactly what Java's ArrayBlockingQueue and Go's buffered channels do internally, and it gives backpressure for free: a slow consumer makes producers block instead of letting memory grow without limit.
Signal or broadcast
Use signal when any one waiter can make progress and all waiters wait for the same condition (one item added, one consumer needed). Use broadcast when the change may let several waiters proceed, or when waiters wait for different conditions on the same condition variable, because signal might wake one that cannot proceed while the one that could keeps sleeping. Broadcast is always correct if waiters loop, at the cost of waking threads needlessly (the thundering herd).
Monitors
A monitor packages shared data, a mutex and condition variables into one object whose methods run with the lock held. Java's synchronized methods with wait and notify on the object, and C#'s lock with Monitor.Wait and Pulse, are monitors built into the language. Most languages also offer higher-level tools built on these (blocking queues, countdown latches, barriers, futures, channels), which are almost always the better choice in application code than raw condition variables.
| Need | Use |
|---|---|
| Hand work from producers to consumers | A blocking queue or channel |
| Wait for N tasks to finish | A countdown latch, WaitGroup, or join on futures |
| Wait for one result | A future or promise |
| All threads reach a point before any continues | A barrier |
| Custom condition over shared state | A mutex plus condition variables, waiting in a loop |
Trade-offs
Condition variables avoid busy-waiting and fixed sleeps, at the cost of rules that are easy to break: hold the lock, wait in a loop, choose signal or broadcast correctly. Monitors make the pattern safer by construction. Higher-level primitives remove most of the risk and should be the default; raw condition variables belong in the implementation of those primitives or in rare custom cases.
In an interview
Use the bounded buffer to talk about backpressure: "The ingest stage hands batches to writers through a bounded queue of 1,000; when writers fall behind, the queue fills and ingest blocks, so memory stays bounded instead of growing until the process is killed." If asked to implement one, write the loop-around-wait form and say why it is a loop.
Check yourself
A consumer does: lock; if (queue.isEmpty()) wait(); item = queue.remove(); unlock. Occasionally it throws because the queue is empty after waking. Why?