Condition variables
intermediate · asked in almost every interview
Waiting, signalling, and the reason the wait goes inside a `while`. Switch the variant to `if` and watch a consumer take an item that is no longer there.
The problem it solves
A thread needs to wait until something becomes true — the queue is non-empty, the connection is ready, the work is done. Spinning on the condition burns a core. Sleeping and rechecking adds latency and still burns cycles. What you want is to be told, and to occupy nothing while waiting.
That is a condition variable: a place to wait, associated with a mutex, that another thread can signal. The waiting thread releases the lock and sleeps; the signalling thread wakes it; it reacquires the lock and continues.
And there is one rule about using it that everybody is told and almost nobody can justify: the wait goes inside a while, not an if. The usual explanation is “spurious wakeups”, which makes it sound like a defensive quirk of the platform — something the standard permits but that rarely happens. That explanation is wrong in the way that matters, because it suggests the risk is exotic. The real reason is completely ordinary and happens constantly.
The mechanism
Here is the sequence, and the gap is in the middle of it.
A waiting thread is woken by a signal. It does not resume holding the mutex — it cannot, because the signaller may still hold it. It goes onto the queue for that mutex like anybody else. Only when it reaches the front does it resume.
Between being woken and reacquiring, other threads run. One of them can take the very item the wakeup was about. So the thread wakes up, waits its turn, gets in — and the condition it was promised is false again.
if asks the question once, before waiting, and trusts that answer forever. while asks again after reacquiring the lock, which is the only moment the answer is worth anything, because it is the only moment the thread both knows the answer and holds the lock that keeps it true.
Spurious wakeups are real and are a second, weaker reason. pthread_cond_wait may return without any signal at all, because permitting that makes the implementation faster on some platforms. But even on a platform with no spurious wakeups whatsoever, the while is still mandatory, for the reason above.
What the enumeration shows
The program is two consumers and a producer adding two items with a broadcast each time. A counter records any consumer that takes from an empty buffer.
With the wait inside a while: 0 of 30 interleavings underflow. Switch the variant to if: 6 of 30 do. One click apart, same program otherwise.
Step through a failing one. Both consumers wait. The producer adds one item and broadcasts, waking both. The first consumer reacquires, sees the item, takes it. The second consumer reacquires — and with if it does not look again. It takes an item that is not there. In real code that is an index off the end of an array, or a null where the type system said there could not be one.
Note also that the check must be caught where it happens. An earlier version of this program checked the final buffer size, which cannot detect the bug at all: two consumers each taking once from two produced items always ends at zero, however badly the middle went.
The third variant, lost-wakeup, shows the other half of the contract: a consumer that waits without checking the state first. The producer signals before anyone is waiting, the signal is discarded — condition variables have no memory — and the consumer then waits forever for something that already happened. One of its two interleavings deadlocks.
The numbers worth carrying
- Wait in a
while: 0 of 30 interleavings take from an empty buffer. Wait in anif: 6 of 30. - Signal before waiting: the signal is lost, not queued. This is the difference between a condition variable and a semaphore, and it is the reason the predicate rather than the notification is the source of truth.
signalwakes one waiter;broadcast/notifyAllwakes all. Broadcast withnwaiters and one item causesn − 1threads to wake, contend for the mutex, find nothing and go back to sleep — the “thundering herd” of condition variables.
Where it breaks down
Signal versus broadcast is a correctness question, not just a performance one. signal is safe only when any single waiter can make use of the event. If waiters are waiting for different conditions on the same variable, signalling one may wake a thread that cannot proceed while the one that could stays asleep. Either use broadcast, or use one condition variable per predicate — the second is better and is what ReentrantLock.newCondition() is for.
The predicate must be checked under the mutex. Checking it outside is check-then-act with extra machinery.
Waiting without a bound can hang forever. awaitNanos/wait_for with a timeout, and a check for what to do when it expires, is the difference between a slow system and a stuck one.
A lost wakeup is not recoverable by waiting harder. If the state changed before you waited, no future signal is coming. This is why the predicate is checked before the first wait, not only after each one — the while loop’s first evaluation is doing that job.
What people get wrong
“The while is for spurious wakeups.” It is for the gap between being woken and reacquiring the mutex, which is not spurious and not rare. Give that answer and you are ahead of most candidates.
“notify is more efficient than notifyAll, so use it.” It is, and it is only safe when all waiters wait for the same condition. Getting this wrong produces a hang that appears under load and vanishes under a debugger.
“The signal will wait for me.” It will not. No waiter, no effect, and the notification is gone.
“I hold the lock, so the condition must still be true.” It is true now, which is why you check now. It was not necessarily true a moment ago when you were told about it.
In production
synchronized + wait/notifyAll in Java, or ReentrantLock + Condition for multiple predicates on one lock — prefer the latter, because separate conditions remove the need for broadcast. std::condition_variable in C++ takes the predicate as an argument (cv.wait(lock, []{ return !queue.empty(); })), which is the API doing the while for you and is the right default. threading.Condition in Python has wait_for. Go has no condition variables in ordinary use because channels cover the same ground — a receive on a channel is waiting for a predicate with the signal built in.
The pattern to write every time:
lock(m)
while (!predicate()) {
wait(cv, m)
}
// predicate holds AND we hold the lock
act()
unlock(m)
And on the signalling side: change the state, then signal, both under the lock. Signalling without changing the state signals nothing; changing the state without signalling leaves waiters asleep.
Most application code should not be here at all. A BlockingQueue, a channel, a semaphore or a latch is a condition variable with the predicate already correct, and the reason to reach for those first is that this page describes the mistakes you get to skip.
The follow-up questions
“Why does wait go in a while?” — The gap between waking and reacquiring, during which another thread can take what you were woken for. Say that before mentioning spurious wakeups.
“When can you use signal instead of broadcast?” — When every waiter is waiting for the same condition and any one of them can consume the event.
“You signalled and nobody woke up. Why?” — Nobody was waiting yet. The signal is not queued; check the state before waiting.
“What does wait do to the mutex?” — Releases it atomically as part of entering the wait, and reacquires before returning. The atomicity is what prevents a lost wakeup between the check and the wait.
In an interview
Everybody has been told to use `while`. Being able to say why, without invoking spurious wakeups, is the differentiator.
- condition variable
- monitor
- spurious wakeup
- notifyAll
Run these next
- MutexesA lock removes orderings from the space. The critical section must span the whole read-modify-write — guarding only the write leaves the window exactly where it was.
- The bounded bufferTwo semaphores facing opposite directions keep the invariant without either side counting the other. A producer that cannot take a slot cannot proceed, and being unable to proceed is the flow control.
- DeadlockA cycle in the wait-for graph is the deadlock. Lock ordering removes it by construction, and it is only a policy when it is a total order over every lock.