AlgoInsight

Free lesson · Concurrency Interview Mastery

Exercise: Condition Variables

Medium6 min readconcurrencymultithreadinginterview-prep

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 while loop? Does wait release 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) if instead of while - the single most common CV bug; always loop. (2) Forgetting wait releases the lock - assuming you must manually unlock before waiting (you don't; wait does it atomically) or that you still hold it during the sleep (you don't). (3) signal where broadcast is required - when distinct conditions share a CV, or when all waiters must wake (like drain/shutdown), signal leaves 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 - after put wake a consumer, after take wake 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-while is 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 - "the if lets a woken thread proceed on a stale condition because a signal only means maybe" is the sentence that lands.

Back to Concurrency Interview Mastery