skip to content

You are implementing a fixed-capacity buffer using a mutex plus condition variables (wait/signal). Why must a waiting thread re-check its condition in a loop rather than a single if, and when does waking only one waiter cause the system to hang?

level: middleimportance: must knowfreq 52%

answer

  1. wait = atomically unlock + sleep + relock
  2. Mesa semantics: state can change before you resume
  3. always while, never if (spurious wakeups too)
  4. one CV + signal = lost wakeup deadlock
  5. two CVs + signal, or one CV + broadcast

basics

~20 s

Between being woken and re-acquiring the lock, another thread may have changed the state again, and some runtimes allow spurious wakeups — so the condition can be false on wake. Always loop. Waking one waiter hangs the system when all waiters share one condition variable and the wake goes to a thread of the wrong kind (producer wakes producer), losing the notification.

solid answer

~60 s

A condition-variable wait has three steps: release the mutex, sleep, re-acquire the mutex on wake. The gap between the signal and re-acquiring the lock is the problem — a third thread can run in that window and consume the item or slot that the wakeup was about. Add spurious wakeups (permitted by most runtimes) and you get the rule: **the predicate must be re-tested in a `while` loop after every wake**, never in an `if`. Single-waiter wakeups hang you when producers and consumers wait on the *same* condition variable. A `signal` picks an arbitrary waiter; if the buffer just became non-full and the runtime wakes another blocked producer instead of the blocked consumer that could actually make progress, the notification is consumed and lost, and the system deadlocks with work available. Two correct fixes: use **two condition variables** (`notFull`, `notEmpty`) so each signal targets the right species and `signal` is safe, or keep one and always `broadcast`. Two conditions plus signal is cheaper — broadcast causes a thundering herd.

code

text · 16 lines
text
put(item):
  lock(m)
  while count == CAPACITY:
      wait(notFull, m)
  slots[tail] = item; tail = (tail+1) % CAPACITY; count++
  signal(notEmpty)
  unlock(m)

take():
  lock(m)
  while count == 0:
      wait(notEmpty, m)
  item = slots[head]; head = (head+1) % CAPACITY; count--
  signal(notFull)
  unlock(m)
  return item

go deeper

for a junior

Know the rule and one reason for it: re-check the predicate in a while loop because the state may have changed by the time you get the lock back.

for a middle

Give both causes (Mesa semantics race plus spurious wakeups), draw the interleaving, and explain why two condition variables let you use single-thread wakeups safely.

for a senior

Discuss lost wakeups as a real deadlock class, the broadcast thundering-herd tradeoff, and when to move to lock-splitting or a ring buffer because the single mutex is the bottleneck.

for a principal

Position hand-rolled condition-variable buffers as something to avoid in favour of a vetted queue primitive, and articulate when the contention profile justifies a specialised structure at all.

## What a condition variable is A **condition variable** is a wait queue attached to a mutex. It offers roughly: - `wait(cv, mutex)` — atomically release `mutex` and block on `cv`; when woken, re-acquire `mutex` before returning. - `signal(cv)` / `notify` — wake **one** arbitrary waiter (if any). - `broadcast(cv)` / `notifyAll` — wake **all** waiters. The crucial part is the word *atomically* in `wait`: releasing the lock and joining the wait queue happen as one step, so a signal issued by another thread cannot slip in between and be missed. That is the whole reason condition variables exist rather than "unlock; sleep; lock". A condition variable carries **no state**. Signalling an empty wait queue does nothing at all — the signal is not remembered. All state lives in the data you protect with the mutex; the CV only says "something you care about may have changed, go look". ## The canonical bounded buffer ``` lock(m) while count == CAPACITY: # predicate loop, not if wait(notFull, m) store item; count++ signal(notEmpty) unlock(m) ``` and symmetrically for take, waiting on `notEmpty` and signalling `notFull`. ## Why the loop, reason 1: the wakeup is a hint, not a guarantee Signalling does not transfer the lock or the state. Timeline with three threads and a full buffer: ``` C1: take() -> count-- (now CAPACITY-1) -> signal(notFull) -> unlock P1: wakes from wait, must now re-acquire m ... still queued P2: arrives fresh, acquires m first, fills the slot, count == CAPACITY P1: finally acquires m ``` If P1 used `if`, it would proceed to write into a full buffer — overwriting an item, corrupting the count, or blowing an assertion. With `while`, P1 re-tests, sees the buffer full again, and goes back to waiting. This is called **Mesa semantics**: the signaller keeps running and the state may change before the waiter resumes. Essentially every real runtime uses Mesa semantics; the alternative (Hoare semantics, where the lock and state are handed directly to the waiter) is a textbook construct. ## Why the loop, reason 2: spurious wakeups Most platforms explicitly permit `wait` to return without any corresponding signal — a consequence of how futexes and POSIX threads interact with signals and internal restarts. A single `if` therefore has a latent bug even in code where no other thread could have raced. The loop makes the code immune to both causes at zero design cost, which is why the predicate loop is a hard rule rather than a style choice. ## The lost-wakeup hang from waking one waiter Suppose you economise and use a **single** condition variable for both waiting reasons — producers and consumers all block on `cv` — plus `signal` (wake one). Capacity 1, one item present, two producers blocked because the buffer is full, one consumer arrives: ``` buffer: [x] waiting on cv: P1, P2 C1: take() -> count = 0 -> signal(cv) -> wakes P1 -> unlock P1: re-tests: count(0) != CAPACITY, proceeds, puts y, count = 1 signal(cv) -> wakes P2 P2: re-tests: count(1) == CAPACITY -> waits again ``` Now no one is running, an item is available, and no consumer is waiting — fine so far. But run the mirror case: several consumers blocked on empty, one producer puts an item and signals; the wakeup lands on another *consumer* only in a healthy system. The failure is when a waiter that cannot make progress consumes the signal and goes back to sleep: the notification is gone forever, because condition variables do not remember signals. The buffer holds work, a thread that could take it is asleep, and nothing will ever wake it. That is a **lost wakeup**, and it is a genuine deadlock — no timeout, no error, just a stalled pipeline. ## The two correct fixes 1. **Two condition variables, `signal` each.** `notFull` holds only producers, `notEmpty` holds only consumers. A signal on `notEmpty` can only reach a thread that can actually proceed. This is the standard, and it is the cheap option. 2. **One condition variable, `broadcast` always.** Correct because every waiter re-tests its predicate, so the one that can proceed will. Cost: a **thundering herd** — with N waiters you wake all N, they all contend for the mutex, N-1 re-test and sleep again. O(N) context switches per operation instead of O(1). Mixing them (one CV plus `signal`) is the classic bug. Note also that even with two CVs you may need `broadcast` if waiters wait on *different* predicates over the same variable — for example a buffer where `put` can add a variable number of items, so a signal aimed at a waiter needing 3 slots may reach one needing 5. ## Multi-producer / multi-consumer notes All of the above is unchanged with many threads on each side; the pattern is inherently MPMC because the mutex serialises access. What changes is contention: with many threads, the single mutex becomes the bottleneck and every operation costs a context switch on the critical path. That is the motivation for lock-splitting (separate head and tail locks, so a producer and a consumer can work simultaneously) or array-based ring buffers with atomic index claims — a different implementation, same external contract.

  • Why does the wait operation have to release the mutex and block atomically?
    Because the predicate was evaluated while holding the mutex. If wait released the lock and then separately went to sleep, another thread could acquire the lock, change the state, and signal in that window — and since a condition variable does not remember signals, the sleeper would never be woken. Atomicity guarantees that any signal issued after the state change reaches a thread that is already queued.
  • When is broadcast required even though you have separate condition variables for full and empty?
    When waiters on the same condition variable are waiting for different predicates — for example a buffer whose put can insert a batch, so one waiter needs one slot and another needs five. A single wake may reach a waiter that still cannot proceed, and it will consume the notification. Broadcast makes every waiter re-test, at the cost of a thundering herd.

saying these in an interview costs you the question

  • Using `if` instead of `while` around the wait and arguing it is safe because "only one thread signals" — spurious wakeups and Mesa semantics both break it.
  • Believing a signal is queued or remembered if no thread is currently waiting; condition variables are stateless.
  • Using one condition variable for both full and empty together with a single-thread wake, which loses notifications and deadlocks.
  • Signalling without holding the mutex and assuming the predicate check and the signal are still ordered correctly.
  • Claiming broadcast is always the safe default with no cost — it is correct but causes O(N) wakeups and mutex contention per operation.

context