Free lesson · Concurrency Interview Mastery
Condition Variables
Semaphores answer "how many" and "has it happened." But often a thread needs to wait for something richer - an arbitrary condition about shared state: "wait until the queue is non-empty," "wait until all workers have checked in," "wait until the connection is authenticated." The condition variable is the primitive for exactly this: it lets a thread sleep until a predicate it cares about becomes true, and be woken when another thread changes the state. It's also the primitive with the most subtle mechanics in this entire module - the rules around it (always loop, never if; wait releases the lock; signal vs broadcast) are among the most reliably tested concepts in concurrency interviews, because getting them slightly wrong produces bugs that work 99% of the time and then fail.
The problem: waiting without burning CPU
Suppose a consumer must wait until a shared queue has an item. The naive approach is to spin:
while queue.isEmpty(): // busy-wait - spins, burning a CPU core for nothing
// do nothing, check again
item = queue.remove()
This works but is terrible: the thread consumes a full CPU core doing nothing but re-checking, while the producer it's waiting for competes for that same CPU to actually add an item. Busy-waiting wastes energy and starves the very thread that would satisfy the condition. What you want is for the waiting thread to sleep - consume no CPU - until there's a reason to wake, then re-check. That's precisely what a condition variable provides.
What a condition variable is
A condition variable is always paired with a mutex (they're inseparable - the mutex protects the shared state the condition is about), and it offers three operations:
- wait - atomically release the associated mutex and block the thread; when later woken, re-acquire the mutex before returning.
- signal / notify - wake one thread waiting on this condition variable.
- broadcast / notifyAll - wake all threads waiting on this condition variable.
The waiting thread holds the mutex, checks the condition, and if it's not satisfied, calls wait - which puts it to sleep and releases the mutex so other threads can change the state. When another thread changes the state and signals, the sleeping thread wakes, re-acquires the mutex, and re-checks. No CPU is burned while waiting.
mutex.lock()
while not conditionIsTrue(): // ALWAYS a loop - see below for why
cond.wait(mutex) // atomically: release mutex + sleep; on wake: re-acquire mutex
// condition is now true AND we hold the mutex
doWork()
mutex.unlock()
// another thread:
mutex.lock()
changeStateToMakeConditionTrue()
cond.signal() // wake a waiter (which re-checks the loop)
mutex.unlock()
sequenceDiagram
participant W as Waiter thread
participant M as Mutex
participant S as Signaler thread
W->>M: lock()
W->>W: check condition -> false
W->>M: wait() atomically RELEASES mutex + sleeps
Note over W: sleeping, holds no lock, burns no CPU
S->>M: lock()
S->>S: change state (condition now true)
S->>W: signal()
S->>M: unlock()
W->>M: wakes, RE-ACQUIRES mutex
W->>W: re-check condition in loop -> true, proceed
This sequence diagram shows how a condition variable coordinates a waiter and a signaler through a mutex. The waiter locks the mutex and checks its condition, which is false. It calls wait, which atomically releases the mutex and puts the thread to sleep - so while waiting it holds no lock and burns no CPU. Meanwhile the signaler locks the mutex, changes the shared state so the condition becomes true, calls signal to wake the waiter, and unlocks. The waiter then wakes up, re-acquires the mutex, and re-checks its condition in a loop. This time the condition is true, so it proceeds. The key mechanics are that wait atomically releases the lock and sleeps, and that the waiter re-checks the condition in a loop after waking.
Why wait must atomically release the lock
This atomicity - wait releasing the mutex and going to sleep as one indivisible step - is the crux of the whole design, and it solves the lost wakeup problem. Imagine if releasing the lock and sleeping were two separate steps:
// BROKEN hypothetical - if release and sleep were separate:
mutex.unlock() // (1) release the lock
// <-- DANGER: another thread could change state and signal RIGHT HERE
sleep() // (2) go to sleep - but the signal already fired and was missed!
If a thread released the lock, and then - before it managed to sleep - another thread changed the state and signaled, that signal would be lost: it fired while no one was officially waiting yet, and our thread would then sleep forever, having missed the wakeup it needed. By making "release the lock and sleep" a single atomic operation, the condition variable guarantees no signal can slip into that gap. This is why condition variables require the mutex and why wait is defined to release-and-sleep atomically - it's not an implementation detail, it's the entire point. Being able to explain the lost-wakeup problem is one of the strongest things you can say about condition variables in an interview.
The most important rule: always loop, never if
Here is the rule that catches more people than any other in concurrency: always re-check the condition in a while loop, never a bare if.
// CORRECT:
while not conditionIsTrue():
cond.wait(mutex)
// WRONG - subtle, works most of the time, then fails:
if not conditionIsTrue():
cond.wait(mutex)
There are two distinct reasons the loop is mandatory:
1. Spurious wakeups. On many platforms, wait is permitted to return without any signal having occurred - a "spurious" wakeup, allowed by the specifications for implementation-efficiency reasons. If you used if, a spurious wakeup would fall through to doWork() with the condition still false. The loop re-checks and goes back to sleep if the condition isn't actually true.
2. The signal-to-reacquire gap (the deeper reason). Even with no spurious wakeups, between the moment a thread is signaled and the moment it re-acquires the mutex, another thread can acquire the mutex first and invalidate the condition. Consider two consumers waiting on a non-empty queue: the producer adds one item and signals, both consumers are eligible, the first to re-acquire the mutex takes the item - and now the queue is empty again, but the second consumer was also woken. With if, that second consumer proceeds to remove from an empty queue. With while, it re-checks, sees the queue empty, and waits again. This is the Mesa semantics that virtually all real systems use: a signal is a hint that the condition might be true, not a guarantee that it is - so you must re-verify.
Important: Always wrap
waitin awhileloop that re-checks the condition. Both spurious wakeups and the signal-to-reacquire gap mean a woken thread cannot trust that its condition holds - it must re-verify, and go back to waiting if it doesn't. This single rule is the most common condition-variable interview question and the most common condition-variable bug. The deep reason is Mesa semantics: a signal means "the condition may now be true, go check," not "the condition is true." If you take one thing from this lesson, take the loop. Writingifinstead ofwhileproduces code that passes testing and review and then corrupts state under the exact race the loop exists to prevent.
Signal vs broadcast
When you change the state, do you wake one waiter or all of them?
- signal / notify wakes one waiting thread. Use it when at most one waiter can make progress from the change - e.g., you added one item to a queue, so one consumer can proceed. Waking one is more efficient (no thundering herd).
- broadcast / notifyAll wakes all waiting threads. Use it when multiple waiters might be able to proceed, or when different waiters are waiting on different conditions tied to the same condition variable, so you can't know which one(s) the change satisfies.
Tip: When unsure,
broadcastis the safe default - it can't cause a missed wakeup, because every potentially-affected thread is woken and re-checks its own condition (the loop handles the ones that shouldn't proceed by sending them back to sleep).signalis an optimization that's correct only when you're certain exactly one waiter should wake and all waiters wait on the same condition. The classic bug is usingsignalwhen multiple distinct conditions share one condition variable: you signal, the wrong waiter wakes, re-checks, goes back to sleep, and the thread that could have proceeded is never woken - a lost wakeup. If different waiters wait for different things on one CV, you mustbroadcast. Reach forsignalonly when you can prove it's safe; otherwisebroadcast.
Condition variables vs semaphores
Both can build producer-consumer, so when do you choose which? A semaphore is a counter - perfect when the condition is literally "how many of something are available." A condition variable is more general: it waits on an arbitrary predicate about shared state, which you express in the loop condition. When your waiting condition is a simple count, a semaphore is cleaner. When it's a richer predicate - "the queue is non-empty and not paused," "all N workers have arrived," "the state is one of these three values" - a condition variable expresses it directly, because you write the actual boolean in the while. Condition variables trade the semaphore's built-in counting for the flexibility to wait on anything you can write as a condition. That generality is why higher-level constructs (monitors, blocking queues) are typically built on condition variables.
A worked example: producer-consumer with condition variables
The semaphore lesson solved the bounded buffer with two counting semaphores plus a mutex. Here's the same problem solved with condition variables, which is illuminating because it shows the more general tool expressing the same coordination - and because the condition-variable version is what you'd actually write when the waiting conditions are richer than simple counts.
mutex = new Mutex()
notFull = new ConditionVariable() // producers wait here
notEmpty = new ConditionVariable() // consumers wait here
buffer = fixed-size queue, capacity N
function produce(item):
mutex.lock()
while buffer.isFull(): // while, never if
notFull.wait(mutex) // sleep until a consumer frees a slot
buffer.add(item)
notEmpty.signal() // a consumer can now proceed
mutex.unlock()
function consume():
mutex.lock()
while buffer.isEmpty(): // while, never if
notEmpty.wait(mutex) // sleep until a producer adds an item
item = buffer.remove()
notFull.signal() // a producer can now proceed
mutex.unlock()
return item
Trace how this maps to the rules from this lesson. There are two distinct waitable conditions - "buffer not full" (what producers wait for) and "buffer not empty" (what consumers wait for) - so we use two condition variables, one per condition, exactly as you'd want. Each wait sits in a while loop that re-checks its condition, so a woken thread that finds its condition no longer true (because another thread got there first) simply waits again. After a produce, the buffer is non-empty, so we signal notEmpty to wake a consumer; after a consume, the buffer is non-full, so we signal notFull to wake a producer. Because each condition variable has exactly one kind of waiter, signal is correct and efficient - each signal reaches a thread that can actually proceed. And every wait atomically releases the mutex while sleeping, so a producer blocked on a full buffer holds no lock, letting consumers acquire the mutex, remove items, and signal notFull.
Compare this to the semaphore version. The semaphore solution encoded the conditions as counts (empty slots, full slots), which is natural here because "is there a free slot" really is a counting question. The condition-variable version encodes the conditions as predicates (buffer.isFull(), buffer.isEmpty()), which is more general - if the waiting condition were richer than a count (say, "not full and not paused and the item passes a filter"), the condition variable expresses it directly in the while, where the semaphore couldn't. This is the core trade-off from the previous section made concrete: semaphores are cleaner when the condition is a count, condition variables are necessary when it's an arbitrary predicate. Being able to write both versions and articulate why you'd choose each is exactly the depth that distinguishes a strong concurrency interview from a passing one. Most real higher-level blocking structures use the condition-variable form precisely because of its generality.
Note: If you internalize one mental model for condition variables, make it this: a condition variable is not where the condition lives - the condition lives in your shared state, and you check it in the
whileloop. The condition variable is just the waiting room and the doorbell:waitis "go sleep in the waiting room (and let go of the lock on the way in)," andsignal/broadcastis "ring the doorbell so sleepers wake and re-check." The condition variable never knows or stores whether your condition is true; it only parks and wakes threads. That's exactly why you must re-check the predicate yourself after every wake - the doorbell ringing doesn't mean what you're waiting for has happened, only that it's worth looking again.
The interview angle
Interview: The highest-value questions in this whole module cluster here: (1) "Why must you use a
whileloop, not anif, aroundwait?" - Spurious wakeups, and (the deeper reason) Mesa semantics: between signal and re-acquiring the lock, another thread can invalidate the condition, so a woken thread must re-verify. A signal means "maybe true, re-check," not "true." (2) "Why doeswaitrelease the lock, and why atomically?" - So other threads can change the state (you can't signal a condition no one can modify), and atomically to prevent the lost-wakeup race where a signal slips between releasing the lock and sleeping. (3) "signal vs broadcast?" - Signal wakes one (efficient, correct when one waiter can proceed and all wait on the same condition); broadcast wakes all (safe default; required when distinct conditions share a CV). (4) "Condition variable vs semaphore?" - CV waits on an arbitrary predicate (general); semaphore counts permits (specific to counting). The loop-not-ifanswer with the Mesa-semantics reasoning is the single strongest thing you can demonstrate here.
Quick reference
CONDITION VARIABLE = wait until an arbitrary predicate becomes true (paired with a mutex).
wait(mutex) -> atomically RELEASE mutex + sleep; on wake, RE-ACQUIRE mutex
signal/notify -> wake ONE waiter
broadcast/notifyAll -> wake ALL waiters
THE PATTERN (memorize this):
mutex.lock()
while not condition: // ALWAYS while, NEVER if
cond.wait(mutex)
// condition true AND holding mutex
mutex.unlock()
// signaler: lock; change state; signal/broadcast; unlock
WHY WHILE NOT IF:
spurious wakeups (wait may return with no signal)
Mesa semantics: between signal and re-lock, another thread can invalidate the condition
-> a signal means "maybe true, RE-CHECK", not "true"
WHY WAIT RELEASES THE LOCK (atomically):
so others can change the state; atomic to avoid LOST WAKEUP
(signal slipping between unlock and sleep)
SIGNAL = wake one (optimization; same condition, one can proceed)
BROADCAST = wake all (safe default; required when distinct conditions share one CV)
You now have the three foundational primitives: mutex (exclusion), semaphore (counting and signaling), and condition variable (waiting on a predicate). The remaining primitives in this module are largely combinations and specializations of these. The next one, the read-write lock, is a specialized mutex that distinguishes readers from writers - and the one after, monitors, packages a mutex and condition variables into a single, safer, higher-level construct.