Free lesson · Concurrency Interview Mastery
Exercise: Condition Variables
This exercise targets the three things that make condition variables tricky: the loop-not-if rule, building a blocking structure from scratch, and choosing signal vs broadcast. The first part is a debugging task - you're handed working-looking code with the classic bug - because spotting that bug is exactly what interviewers ask. Reason each part through before the solution; with condition variables especially, the bugs are subtle enough that reading the answer teaches far less than finding it.
Important: Condition-variable bugs are the kind that pass casual testing and fail under load, so train your eye to spot them statically. For each part, ask the three diagnostic questions: Is the wait in a
whileloop? Doeswaitrelease the lock so others can change the state? Is signal-vs-broadcast correct for who needs to wake? Most CV bugs are a wrong answer to one of those three.
Part 1 - Find the bug
Here's a bounded-buffer consumer that looks correct and works in light testing, then occasionally crashes by removing from an empty buffer.
function consume():
mutex.lock()
if buffer.isEmpty(): // <-- look carefully
cond.wait(mutex)
item = buffer.remove() // sometimes runs on an empty buffer -> crash
mutex.unlock()
return item
Your task: Explain the exact scenario where this removes from an empty buffer despite the if check, and fix it. Name both reasons the fix is necessary.
<details> <summary>Hint 1</summary>
The if checks the condition once, before waiting. After wait returns, the code assumes the buffer is non-empty - but does anything guarantee that's still true?
</details>
<details> <summary>Hint 2</summary>
Two waiters, one producer that adds a single item and signals. Both waiters were sleeping. What happens when both wake but there's only one item? Also consider: can wait ever return without a signal at all?
</details>
Part 2 - Build a blocking queue
Implement a thread-safe queue with capacity N where put blocks if the queue is full and take blocks if it's empty, using a mutex and condition variable(s).
function put(item):
// TODO: block while full, then add, then wake a waiting consumer
function take():
// TODO: block while empty, then remove, then wake a waiting producer
Your task: Implement both methods. Decide whether you need one condition variable or two, and justify it. Make sure every wait is in a loop and you wake the right side after each operation.
<details> <summary>Hint</summary>
There are two distinct conditions: "not full" (producers wait on this) and "not empty" (consumers wait on this). You can use two condition variables (one per condition, cleaner) or one (then you must broadcast, because a single CV can't distinguish which side to wake). After a put, the queue is non-empty, so wake a consumer; after a take, it's non-full, so wake a producer.
</details>
Part 3 - signal vs broadcast
A pool hands out resources. Multiple threads wait when the pool is empty. Sometimes a thread that could get a freed resource is never woken, and hangs forever. Here's the release code:
function release(resource):
mutex.lock()
pool.add(resource)
cond.signal() // wake one waiter
mutex.unlock()
Separately, the pool also supports a drain() that, when called, must wake every waiting thread so they can observe the pool is shutting down and exit. It also uses cond.signal().
Your task: Identify which of these uses of signal is wrong and why, and state the rule for when signal is safe versus when you must broadcast.
<details> <summary>Hint</summary>
signal wakes exactly one waiter. For release adding one resource, one waiter can proceed - is signal correct there? For drain needing all waiters to wake and exit, what happens if you only wake one?
</details>
Solution
Part 1 - The bug and the fix
The bug is the if. The failing scenario: two consumers are both asleep in wait. A producer adds one item and calls signal/broadcast, waking the consumers. Both were waiting; both now proceed past their if (which already ran and won't re-check). The first to re-acquire the mutex removes the one item - now the buffer is empty again - but the second consumer also falls through to buffer.remove() and removes from an empty buffer, crashing.
The fix is while:
function consume():
mutex.lock()
while buffer.isEmpty(): // re-check after every wake
cond.wait(mutex)
item = buffer.remove()
mutex.unlock()
return item
Both reasons the loop is necessary: (1) Mesa semantics / the signal-to-reacquire gap - the scenario above: between being signaled and re-acquiring the lock, another thread can take the item and invalidate the condition, so the woken thread must re-verify. (2) Spurious wakeups - wait is permitted to return with no signal at all on many platforms, which would also fall through an if. With while, a woken thread that finds the condition false simply waits again. A signal means "the condition may be true now - re-check," never "it is true," which is exactly why you loop. This is the single most-tested condition-variable point, and the answer must name both reasons.
Part 2 - Blocking queue
Cleanest with two condition variables, one per condition:
notFull = condition variable
notEmpty = condition variable
function put(item):
mutex.lock()
while queue.size() == N: // wait while full
notFull.wait(mutex)
queue.add(item)
notEmpty.signal() // queue is now non-empty: wake a consumer
mutex.unlock()
function take():
mutex.lock()
while queue.isEmpty(): // wait while empty
notEmpty.wait(mutex)
item = queue.remove()
notFull.signal() // queue is now non-full: wake a producer
mutex.unlock()
return item
Justification for two CVs: with separate notFull and notEmpty, a put can signal exactly a consumer (on notEmpty) and a take can signal exactly a producer (on notFull) - each signal reaches a thread that can actually proceed, so signal is correct and efficient. With a single CV for both conditions, a signal might wake a producer when you needed to wake a consumer (or vice versa); that woken thread re-checks, finds its condition still false, and goes back to sleep, while the thread that should have proceeded is never woken - a lost wakeup. So with one CV you'd be forced to broadcast every time (wake everyone, let the loops sort it out), which works but is wasteful. Two condition variables let each signal target the right side precisely. Every wait is in a while, satisfying Part 1's rule.
Part 3 - signal vs broadcast
The release use of signal is correct: adding one resource lets exactly one waiter proceed, and all waiters here wait on the same condition (pool non-empty), so waking one is right and efficient.
The drain use of signal is wrong: drain needs every waiting thread to wake and observe the shutdown, but signal wakes only one. The other waiters stay asleep forever, never learning the pool is draining - they hang. drain must use broadcast:
function drain():
mutex.lock()
draining = true
cond.broadcast() // wake ALL waiters so each can observe shutdown and exit
mutex.unlock()
The rule: use signal when waking one waiter is sufficient and all waiters wait on the same condition (so any one of them can use the wakeup). Use broadcast when multiple waiters need to wake, or when distinct conditions share one condition variable (so you can't know which waiter the change satisfies). broadcast is the safe default - it never causes a missed wakeup - while signal is an optimization valid only when you can prove one wake suffices. Drain is the textbook "must broadcast" case: all waiters must respond.
Common mistakes
Warning: The recurring errors: (1)
ifinstead ofwhile- the single most common CV bug; always loop. (2) Forgettingwaitreleases the lock - assuming you must manually unlock before waiting (you don't;waitdoes it atomically) or that you still hold it during the sleep (you don't). (3)signalwherebroadcastis required - when distinct conditions share a CV, or when all waiters must wake (like drain/shutdown),signalleaves threads stranded. (4) Signaling without holding the mutex / changing state - you must change the shared state under the lock before signaling, or the woken thread re-checks, finds nothing changed, and sleeps again. (5) Waking the wrong side in a blocking queue - afterputwake a consumer, aftertakewake a producer; mixing this up with a single CV needs broadcast to be safe.
Why this exercise matters for interviews
Interview: Part 1 is a real interview question - "what's wrong with this consumer?" with
if-instead-of-whileis asked verbatim at top companies, and naming both reasons (Mesa semantics and spurious wakeups) is the distinguishing answer. Part 2 (implement a blocking/bounded queue) is among the most common concurrency coding problems, and choosing two condition variables with the right signal targets demonstrates real fluency. Part 3 (signal vs broadcast, with a shutdown/drain that must broadcast) tests whether you understand that a signal can be a lost wakeup when misapplied. Together they cover the condition-variable questions you're most likely to face. Practice articulating Part 1 out loud - "theiflets a woken thread proceed on a stale condition because a signal only means maybe" is the sentence that lands.