5.2.25 · D5C++ Programming

Question bank — std - condition_variable

1,932 words9 min readBack to topic

Before you start, keep three plain-English facts on a sticky note, because almost every trap reduces to one of them:

Recall The three facts every trap leans on

Fact 1 — no memory: a notify sent while nobody is waiting simply vanishes. The condition_variable does not queue it up for later. Fact 2 — the flag carries the truth: correctness comes from the shared predicate (a boolean over shared state), not from the notification. The notify is only a "wake up and re-check" nudge. Fact 3 — the same mutex guards both the check and the change: the waiter's predicate-check and the notifier's state-change must be serialized by the same mutex, or a wakeup can slip through the crack.


Picture the machinery first

Before the traps, ground your eyes on four pictures. Every trap below is just one of these diagrams gone wrong — when you get stuck on a question, come back and point at the arrow that breaks.

Figure 1 — the correct handshake. This is a swim-lane timeline: the Consumer lane on the left, the Producer lane on the right, and time flows downward. Notice the three atomic steps inside wait: it releases the mutex (violet arrow leaving the lane), sleeps (grey bar), and re-acquires the lock (violet arrow returning) only after the notify. The predicate is checked twice — once before sleeping, once after waking.

Figure 2 — the lost-wakeup race. Same two lanes, but the producer flips the flag and notifies without holding the lock, landing in the tiny window (red band) after the consumer read false but before it slept. The notify (orange) hits an empty room and vanishes; the consumer sleeps forever. This is the "Spot the error" ready=true bug drawn out.

Figure 3 — the predicate loop unfolding. A single waiter's life as a flowchart-timeline: each wakeup (spurious or real) loops back to re-check the predicate. Follow the two dashed return-arrows — one for a spurious wakeup, one for a stolen item — both dump you back to "check pred", proving why while (not if) is mandatory.

Figure 4 — notify_one vs notify_all. Left panel: three sleeping consumers, notify_one wakes exactly one, the other two stay asleep. Right panel: notify_all wakes all three — the thundering herd — but only one grabs the single item; the other two re-check, find the queue empty (stolen wakeup), and sleep again.


True or false — justify

Every claim below is stated as if it were obviously true. Decide, then justify with reasoning — never a bare yes/no.

cv.notify_one() guarantees that at least one thread will eventually wake and proceed.
False. If no thread is currently waiting, the notify is lost (Fact 1, Figure 2); a thread that starts waiting afterward never sees it and can sleep forever unless the shared flag already reads true.
A condition_variable remembers how many notifies it received so a late waiter can catch up.
False. It has no counter and no memory. This is exactly why we test a real shared predicate — the flag survives, the notification does not.
You must hold the mutex while calling notify_one().
False. Notify does not require the lock. But you must have changed the shared state under the lock beforehand; unlocking before notifying is a legal optimization, not a requirement.
Using cv.wait(lk, pred) and using while(!pred()) cv.wait(lk); are equivalent.
True. The predicate overload is defined to expand to that exact loop (Figure 3), which is why both correctly survive spurious and stolen wakeups.
A spurious wakeup means the OS lost data and the program is now corrupt.
False. A spurious wakeup is a permitted, harmless event where wait returns without a matching notify. The loop simply re-checks the predicate and sleeps again — no corruption, just an extra check.
If only one producer and one consumer exist, if is safe instead of while.
False. Spurious wakeups are independent of thread count; a lone consumer can still wake with the queue empty. The loop (or predicate overload) is always required.
notify_all() is always safer than notify_one(), so just use it everywhere.
Misleading, treat as false. It is correct more often, but with many waiters it wakes them all to contend for one lock — a "thundering herd" (Figure 4) that wastes CPU. Use notify_one() when a single waiter can handle the change; use notify_all() when the state (like done) concerns everyone.
cv.wait(lk) keeps the mutex locked the whole time it sleeps.
False. wait atomically releases the lock and sleeps, then re-acquires it before returning (Figure 1). If it held the lock while sleeping, no notifier could ever change the state — instant deadlock.
Passing a std::lock_guard to cv.wait is fine as long as you never manually unlock.
False. wait itself needs to unlock and relock the mutex, and lock_guard exposes no unlock. It won't even compile; you need unique_lock.

Spot the error

Each snippet has a subtle bug. Name it and say what breaks.

ready = true; cv.notify_one(); (flag set with no lock held).
Lost-wakeup race (Figure 2). Between the waiter reading ready==false and calling wait, the notifier can flip ready and notify into the void; the waiter then sleeps forever. Set the flag under the same mutex.
std::unique_lock<std::mutex> lk(m); if (q.empty()) cv.wait(lk); process(q.front());
if instead of a loop (Figure 3). A spurious wakeup (or a stolen item under notify_all) leaves the queue empty, and q.front() reads from an empty queue — undefined behaviour. Use cv.wait(lk, []{ return !q.empty(); });.
Producer: q.push(x); then cv.notify_one(); — but q is not guarded by any mutex.
Data race on the queue. Concurrent push/pop on std::queue without a lock is undefined behaviour. The mutex must protect every access, not just the wait.
Waiter locks mutex A; notifier changes the flag under a different mutex B, then notifies.
Broken serialization (Fact 3). The waiter's check and the notifier's change are guarded by different locks, so the check-then-wait window is unprotected — lost wakeups return. Both sides must share one mutex.
cv.wait(lk, [&]{ return counter > 0; }); counter--; but counter is a plain int written by another thread without the lock.
Torn/stale read. Reading counter under the lock but writing it lock-free is a data race; the predicate may observe an inconsistent value. Either guard all accesses with the mutex or use atomic consistently — do not mix.
Consumer: lk.unlock(); cv.wait(lk); (manually unlocks the unique_lock before calling wait).
Undefined behaviour. cv.wait requires the lock to be held on entry so it can perform the atomic unlock-and-sleep (Figure 1). Passing an already-unlocked lock violates the precondition.

Why questions

::: answers here are the reasoning itself, not a fact to memorize.

Why does wait take a mutex reference instead of just a raw boolean flag?
The mutex is what makes "check the predicate" and "go to sleep" one atomic step; without it, a notify could land in the gap between checking false and sleeping (the lost-wakeup window, Figure 2).
Why does the standard permit spurious wakeups instead of forbidding them?
Forbidding them would force expensive guarantees on every OS/hardware combination; allowing them keeps wait cheap, and the mandatory predicate loop (Figure 3) makes them harmless anyway.
Why prefer unlocking before calling notify_one()?
If you notify while still holding the lock, the woken thread immediately tries to lock, finds it held, and blocks again — a wasted wake-and-sleep context switch. Unlocking first lets it proceed on first wake.
Why can the flag, not the notification, be trusted as the source of truth?
Because notifications are ephemeral (Fact 1) but the flag persists in shared memory; a late-arriving waiter re-reads the flag and gets the correct answer even after every notify has been sent.
Why does notify_all() fit the "producer is done" signal better than notify_one()?
done is a fact every consumer must observe to exit; waking only one leaves the rest asleep forever (Figure 4). State that concerns all waiters demands notify_all.
Why is a busy-wait while(q.empty()){} loop worse than cv.wait, beyond just CPU?
Besides burning 100% CPU, on a single core it can starve the very producer that would fill the queue, so it may spin forever; wait sleeps at 0% CPU and yields to the producer.
Why does cv.wait(lk, pred) re-acquire the lock before returning, even after a spurious wakeup?
So that whatever code follows wait runs with the shared state protected; the caller always resumes in the same "lock held, predicate true" state (Figure 1), keeping the reasoning uniform.

Edge cases

The scenarios the happy path never shows you.

A notify_one() fires and no thread is waiting yet — what happens to a thread that starts waiting one microsecond later?
The notify was lost (Fact 1), so the newcomer relies entirely on the predicate. If the flag was set under the lock, its wait(lk, pred) sees true immediately and does not sleep; if not, it sleeps forever.
Two consumers wait, producer pushes one item and calls notify_all().
Both wake and race for the lock (Figure 4, right). The first re-checks the predicate, takes the item, and empties the queue; the second re-checks, finds it empty, and sleeps again. This "stolen wakeup" is precisely why the loop is mandatory.
The predicate is already true before the thread ever waits (item pushed before the consumer arrives).
cv.wait(lk, pred) checks the predicate first; finding it true, it returns immediately without sleeping and without needing any notify — the flag alone carried the state.
The producer finishes and sets done, but a consumer is mid-processing, not waiting.
No problem: the consumer isn't asleep, so a missed notify is irrelevant. When it loops back to wait(lk, pred), the predicate !q.empty() || done reads done == true from shared memory and it exits cleanly.
A notify_one() is issued before any producer ever changes the state, purely by mistake.
If it lands with no waiter, it's lost harmlessly. If it wakes a waiter, that waiter re-checks the still-false predicate and simply goes back to sleep (Figure 3) — the predicate loop absorbs the stray notify.
Every waiting thread wakes at once under notify_all() and they all fail their predicate.
They each re-lock, re-check, find false, and return to sleep. Correctness holds, but this is wasted work — a reason to prefer notify_one when only one waiter can act on the change.
A one-shot "go" flag is set to true, consumers notified, then a brand-new thread joins and waits.
Since the flag stays true in shared memory, the newcomer's predicate check passes instantly. Unlike the notification, the flag is durable — this is why a level-triggered flag beats an edge-triggered notify.