skip to content

A worker occasionally blocks forever waiting for data that was, in fact, produced. Explain the lost-wakeup bug on a condition variable and the exact locking protocol between the producer and the waiter that prevents it.

level: seniorimportance: should knowfreq 45%

answer

  1. signal is edge-triggered, never stored
  2. gap between test and entering the wait set = the hole
  3. producer must mutate state under the SAME mutex
  4. hang with LOW cpu, state already satisfies predicate
  5. broadcast hides wrong-waiter bugs; missing notify it cannot fix

basics

~20 s

A signal is not stored: if it fires when nobody is in the wait set, it vanishes. If the producer changes state and signals in the window after the waiter tests its predicate but before it is registered as waiting, the waiter sleeps forever. Prevention: test the predicate and wait under the same mutex that the producer holds while changing state and signalling.

solid answer

~60 s

Condition-variable signals are **edge-triggered and not remembered**. A signal delivered when the wait set is empty is simply lost. The bug appears whenever there is a gap between *checking the predicate* and *being registered as a waiter*: ``` waiter producer read ready == false lock; ready = true; signal(); unlock <- nobody waiting lock; wait() // sleeps forever, though ready is true ``` The protocol that closes it has three parts and all three are required: 1. The waiter tests the predicate **while holding the mutex** and calls `wait`, which atomically releases the mutex and enters the wait set. 2. The producer mutates the state **while holding the same mutex**, then signals. 3. The waiter re-tests in a `while` loop. Together they make the interval "predicate false ⇒ I am in the wait set" indivisible with respect to the producer: the producer cannot get the mutex to change the state during it. The other common variant — the producer forgetting to signal at all on one code path — has the same symptom and is not fixed by the loop; every path that can make a predicate true owes a notification.

code

text · 5 lines
text
time ->
W: read ready == false            (outside the lock, or lock released)
                       P: lock(m); ready = true; signal(); unlock(m)
                          ^ wait set is EMPTY: the signal evaporates
W: lock(m); wait(m)               // sleeps forever; ready has been true all along

go deeper

for a junior

Know that a signal is not saved, so if it fires before the thread is waiting it is lost, and that both the wait and the state change must happen under the same lock.

for a middle

Show the losing interleaving explicitly and argue why holding the mutex across the predicate test and the atomic release-and-enqueue leaves no third interleaving.

for a senior

Cover the missing-notification variant (new code paths, shutdown, wrong waiter woken), the low-CPU-hang diagnosis from a thread dump, and why broadcast is a diagnostic rather than a fix.

for a principal

Argue for designing the hazard out — encapsulated blocking structures, level-triggered primitives where the condition is naturally a count, bounded waits so hangs become alerts, and shutdown as a first-class predicate on every wait loop.

## What "lost wakeup" means A condition variable is **edge-triggered with no memory**. `signal` wakes one thread *currently* in the wait set; if the set is empty it is a no-op. Nothing is buffered, so a signal that arrives "too early" is gone. When the waiter subsequently sleeps, nothing will ever wake it — even though the condition it wanted is already true. The result is an indefinite hang, usually intermittent and load-dependent. Contrast this with a *level-triggered* mechanism such as a semaphore, whose permit count persists: a release before any acquire is remembered, and the later acquire succeeds immediately. That difference is precisely why semaphores are hard to lose signals with, and condition variables are easy. ## Variant 1: the check-then-wait race ``` // BROKEN waiter: if not ready: // read OUTSIDE the lock (or lock released here) lock(m); cond.wait(m); unlock(m) producer: lock(m); ready = true; cond.signal(); unlock(m) ``` Interleaving: the waiter reads `ready == false`; the producer runs entirely; the waiter then enters the wait set. The signal is spent, `ready` is true, and the waiter sleeps forever. The fix is not to "check faster" — no amount of narrowing removes the window. It is to make the window **unreachable by the producer**, by covering the check *and* the transition into the wait set with the mutex the producer must hold: ``` lock(m) while not ready: // tested under m cond.wait(m) // atomically: release m + enter wait set unlock(m) ``` Now the producer either acquires the mutex *before* the waiter's test — so the test sees `ready == true` and no wait happens — or *after* the waiter is already registered — so its signal finds a waiter. There is no third interleaving. This is why `wait` requires the mutex to be held: the requirement exists to make this exact race impossible. ## Variant 2: signalling without the lock, with a racy predicate ``` // SUSPECT producer: ready = true // mutated without m cond.signal() // signalled without m ``` Signalling *after* releasing the mutex is legal and sometimes desirable, but mutating the predicate's state outside the mutex is not. If `ready` is written without the lock, the waiter can evaluate it as false and enter the wait set in a window where the producer's write and signal both slip past. The rule: **the predicate's state must be mutated under the mutex**; the signal itself may be inside or immediately after the critical section, but the state change may not be outside it. ## Variant 3: the missing notification (same symptom, different cause) A thread hangs not because the signal was lost in a race, but because no signal was ever sent: - A new code path was added that makes the predicate true (an error injection, a shutdown flag, a capacity increase) and nobody added the notification. - `signal` woke *one* waiter whose predicate was false while a different waiter's predicate was true — the wake-up went to the wrong thread and was consumed. Sharing one condition variable between distinct predicates makes this routine; a broadcast, or one condition per predicate, avoids it. - Shutdown: threads waiting on "queue non-empty" are never woken because shutdown only sets a flag. Termination must be part of the predicate (`while queue.empty() and not shuttingDown`) and must broadcast. Waiting in a loop does **not** help here — the loop only makes wake-ups safe, it cannot invent one. This is why review checklists pair the two rules: wait in a loop, and notify from every state transition that could satisfy someone's predicate. ## Diagnosing it in a running system Symptoms: throughput drops to zero or a subset of workers goes permanently idle while work is queued; CPU is *low* (distinguishing it from a livelock or spin); no exception anywhere. Procedure: 1. Dump all threads. Threads parked in a condition wait, with the guarded state visibly satisfying their predicate, is the signature. 2. Confirm the state: if the queue is non-empty while consumers wait on "not empty", the wake-up was lost or never sent. 3. Enumerate every writer of that state and check each path signals under the correct mutex — including error paths, shutdown paths and recently added branches. 4. Check whether one condition variable serves multiple predicates; if so, either split it or broadcast. Mitigation while you hunt: switching `signal` to `broadcast` often makes the hang disappear, which is diagnostic (it points to a wrong-waiter wake-up) but is a band-aid, not a fix — it does not help if no notification is sent at all. ## Defensive design - **Bounded waits.** A wait with a deadline converts an eternal hang into a detectable, loggable timeout — the difference between a support ticket saying "it stopped" and an alert naming the condition. - **Encapsulate.** A reviewed blocking queue or a well-tested monitor class removes the opportunity for each call site to get this wrong. - **Prefer level-triggered where it fits.** If your condition is naturally a count of available items or permits, a semaphore's persistent permits sidestep lost wakeups entirely. - **Make termination a first-class predicate.** Every waiting loop should have a way to be woken for shutdown, or your process will hang on exit.

  • Why does a semaphore not suffer from lost wakeups the way a condition variable does?
    A semaphore is level-triggered: a release increments a persistent permit count, so a permit produced before anyone waits is still there when a thread later acquires. A condition-variable signal is edge-triggered and stateless — if no thread is in the wait set at that instant, it is discarded. That is why conditions must be paired with an explicit predicate over guarded state, which plays the role the semaphore's count plays automatically.
  • Changing signal to broadcast made an intermittent hang disappear. Is the bug fixed?
    Not necessarily, and the change is diagnostic rather than curative. It usually means one condition variable served multiple predicates and the single wake-up was going to a thread whose predicate was false, consuming the notification. The real fix is one condition variable per predicate, or a deliberate broadcast documented as such. If instead some path changes state without notifying at all, broadcast will not help, because there is no notification to widen.
  • How do you tell a lost wakeup apart from a deadlock in a thread dump?
    A deadlock shows threads blocked while acquiring locks, in a cycle where each holds what another wants. A lost wakeup shows threads parked in a condition wait, holding nothing, with the guarded state already satisfying the predicate they are waiting for — for example consumers waiting on 'not empty' over a queue that visibly has items. Both show low CPU, which distinguishes them from a livelock or a spin loop.

A receptionist calls your name once and does not write it down. If you were still walking to the waiting room when she called, nobody will ever call again — you sit there forever while your appointment slot exists. Being registered in the room before she can possibly call is the only fix.

saying these in an interview costs you the question

  • Believing a signal is queued and will be delivered to a thread that starts waiting later.
  • Mutating the predicate's state outside the mutex and relying on signal ordering for correctness.
  • Thinking waiting in a loop prevents lost wakeups — it only makes wake-ups safe, it cannot create a missing one.
  • Shipping broadcast as the fix without identifying why the single wake-up reached the wrong thread.
  • Waiting without any deadline, so a lost wakeup becomes a silent permanent hang instead of a logged timeout.
  • Forgetting that shutdown must both be part of the waiting predicate and trigger a notification.

context