Synchronization Primitives

Spurious Wakeups: Why It Is `while`, Not `if`

A condition variable may return from wait without any notification having been sent. That is permitted behaviour in POSIX, in C++, in Java and in Python — and even where it is not, another task can consume the state between the notify and your wake. Both reasons lead to the same rule: re-check the predicate in a loop, always.

▶ Run the lab

The question this answers

The question

Why must the predicate be re-checked after wait returns, and which languages and APIs does that apply to?

The work

Four consumer tasks waiting on one condition variable for a shared bounded queue, with a producer that adds a single item and calls notify_all.

What is shared

The queue and its length under a mutex. The condition variable holds no state; the predicate queue.length > 0 is evaluated over the mutex-protected queue.

The invariant — what must stay true under every interleaving

A task that returns from wait and proceeds does so only when the predicate it waited for is actually true at that instant, with the lock held.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

One character, two entirely different programs

The difference between if and while here is not a style preference and not defensive programming. It is the difference between a program that is correct and one that is correct most of the time. wait may return for three distinct reasons, and only one of them means what the naive reading assumes.

First, a genuine notification aimed at you. Second, a *spurious* wakeup: the implementation returned with no notification at all. This is explicitly permitted — POSIX allows pthread_cond_wait to return spuriously, C++ says the same of std::condition_variable::wait, Java's Object.wait documents it, and Python's threading.Condition.wait does too. It happens for real implementation reasons, most commonly when a signal interrupts the underlying futex wait, and allowing it lets implementations be substantially faster on the common path. Third — and this one is not spurious at all, merely misunderstood — a genuine notification whose state was consumed by someone else before you re-acquired the mutex.

That third reason is the important one, because it exists in *every* implementation, including any that never wakes spuriously. notify_all with four waiters and one item means three of them will wake correctly, find nothing, and must go back to sleep. A barging task that never waited at all can take the item between the notify and your wake. So even if spurious wakeups were abolished tomorrow, while would still be mandatory — which is why the rule is better stated as "a wakeup is a hint to re-check" than as "guard against spurious wakeups".

`if` — correct on most wakeups, and corrupt on the rest
1def get(self):
2 with self._cond:
3 if not self._items: # <-- checked exactly once
4 self._cond.wait()
5 return self._items.popleft() # <-- assumes the predicate is true now
6
7# Three ways this line fails, all of them ordinary:
8#
9# 1. SPURIOUS WAKEUP. wait() returned with no notify. _items is still
10# empty. popleft() raises IndexError from an empty deque.
11#
12# 2. notify_all() WITH ONE ITEM AND FOUR WAITERS. All four wake, all four
13# reach popleft(). One succeeds; three raise IndexError - and they were
14# all notified correctly, about an item that genuinely existed.
15#
16# 3. BARGING. A fresh consumer acquires the lock between the notify and
17# this task re-acquiring it, and takes the item. Same IndexError.
18#
19# The stack trace points at popleft(). The cause is in another task, three
20# steps earlier, and the queue was demonstrably non-empty at notify time.
`while` — the predicate is the condition for proceeding, not the wakeup
1def get(self):
2 with self._cond:
3 while not self._items and not self._closed:
4 self._cond.wait() # may return for ANY reason; we do not care
5 if not self._items:
6 raise QueueClosed() # woken by close(), not by an item
7 return self._items.popleft() # the predicate was true, under the lock
8
9# The loop makes all three cases identical and harmless:
10# spurious wake -> predicate false -> wait again
11# lost the race -> predicate false -> wait again
12# real item, won -> predicate true -> proceed
13#
14# Note the second clause. Once a second reason to wake exists (closing),
15# the loop must distinguish them, and the code after the loop must handle
16# "woken, predicate for items still false, but we should not wait again".
17# This is why `while` scales to real systems and `if` does not: adding a
18# second wake reason to an `if` version silently breaks it.
19
20# C++ writes the same loop for you, and this form should be preferred:
21# cv.wait(lk, [&]{ return !items.empty() || closed; });
22# which is defined as: while (!pred()) cv.wait(lk);

The while version treats a wakeup as what it is — a hint to look again — rather than as a promise about the state. That single reframing makes spurious wakeups, multi-waiter competition, barging and future additional wake reasons all collapse into one already-handled case. The if version is correct only when there is exactly one waiter, exactly one reason to wake, no barging and no spurious returns, which is a set of assumptions no codebase preserves for long.

Four waiters, one item

The schedule below is the everyday version of this, and it involves no spurious wakeup at all — every wake is a genuine notification. A producer adds one item and calls notify_all because it does not know how many consumers are parked. Four wake. One gets the item. Three find an empty queue.

With while, those three re-evaluate, find the predicate false, and park again. Total cost: three wasted wakeups and three lock acquisitions — the small thundering herd that is the price of notify_all and the reason to prefer notify when exactly one waiter can proceed per state change. With if, those three call popleft on an empty deque and raise.

The failure has a distinctive and misleading shape in an incident review: three exceptions fire simultaneously, all with identical stack traces pointing at the dequeue, at the exact moment work arrived. The natural reading is "the queue is corrupt". The actual cause is a one-character bug in the consumer, and the queue was in a perfectly valid state at every instant.

Genuine notifications, no spurious wakeups, and three consumers find nothing.ILLUSTRATIVE
Invariant · a task that proceeds past wait() does so only when the predicate is true under the lock
#Consumer 1Consumer 3Producer — one itemState
1acquire; predicate false; wait() — parks··items=0 waiters=1
2·acquire; predicate false; wait() — parks (4 waiters total)·items=0 waiters=4
3··acquire lock; items.append(job-1)items=1 waiters=4
4··notify_all() — all four waiters become runnableitems=1 waiters=0
5··release lockitems=1 lock=free
6wait() returns; re-acquires lock; re-checks: items = 1 → proceed··items=1 lock=C1
7popleft() → job-1; release··items=0 lock=free
8·wait() returns; re-acquires lock; re-checks: items = 0 → WAIT AGAIN·items=0 waiters=1
9·IF `if` WERE USED: popleft() on an empty deque·items=0
✕ IndexError in three consumers at once, each correctly notified, at the moment work arrived. The trace blames the queue; the bug is one character in the consumer.
10·SPURIOUS CASE: wait() returns with no notify at all; re-checks: items = 0 → wait again·items=0 waiters=1
Every wakeup in this trace was legitimate and the queue was valid at every instant. Three consumers still found nothing, because notify_all wakes everyone and there was one item. The loop turns all four scenarios — genuine win, genuine loss, barging loss and truly spurious return — into one code path. That collapse is the whole value: you stop reasoning about *why* you woke and reason only about whether you may proceed.

Which languages and APIs this applies to

The permission to wake spuriously is explicit in the specifications, and the table below cites where. But the more useful framing for a reviewer is broader: any wait-until-a-condition API needs a loop, whether or not its documentation mentions spurious returns, because multi-waiter competition and barging produce the same requirement.

The exceptions are instructive. A future or promise resolves exactly once and remembers its value, so awaiting an already-resolved promise completes immediately and awaiting one twice gives the same answer — there is no predicate to re-check because the primitive carries the state. A latch, once released, stays released. A semaphore permit is stored, so an acquire that succeeds has genuinely taken something. These primitives do not need loops precisely because they hold state, which is the same property whose absence makes condition variables require one.

The practical rule for review: if the primitive is a *parking area* (condition variable, monitor, park/unpark), loop. If it is a *value* (future, latch, permit, resolved promise), do not — take the value.

Where the specification says it, and what the correct form looks like — Wait until a shared queue is non-empty, then take one item.
C++LANGUAGE-SPECIFIC
1// [thread.condition.condvar]: wait "may block ... spuriously".
2// The predicate overload exists precisely so you cannot get it wrong:
3std::unique_lock lk(m);
4cv.wait(lk, []{ return !q.empty() || done; }); // == while(!pred()) cv.wait(lk);
5
6// The raw form, if you must write it yourself:
7while (q.empty() && !done) cv.wait(lk);
8
9// Timed variants return a status AND may still wake spuriously, so the
10// predicate overload is doubly preferred:
11cv.wait_for(lk, 100ms, []{ return !q.empty(); }); // returns pred() at the end

The standard explicitly permits spurious wakeup, and the predicate overload of wait is defined as the loop. Prefer it — there is no reason to hand-write the while in modern C++.

PythonCPYTHON
1# threading.Condition.wait: "may return ... spuriously" - and CPython
2# provides wait_for(), which is the loop, written once and correctly:
3with cond:
4 cond.wait_for(lambda: items or closed) # == while not pred(): wait()
5 ...
6
7# Manual form:
8with cond:
9 while not items and not closed:
10 cond.wait()
11
12# asyncio.Condition has identical semantics and the same requirement:
13async with cond:
14 await cond.wait_for(lambda: bool(items))
15
16# NOT needed for these - they carry state:
17ev = threading.Event(); ev.wait() # stays set; no loop
18sem.acquire() # a permit is taken; no loop
19await some_future # resolves once, remembers; no loop

wait_for(predicate) is the loop and should be the default. The distinction to hold onto: Event, Semaphore and futures carry state and need no loop; Condition parks and therefore does.

JavaScriptBROWSER
1// There is no condition variable in single-isolate JavaScript, because
2// nothing preempts a synchronous block. Coordination is a promise, which
3// carries its value - so no loop is needed:
4const ready = loadIndex()
5await ready // resolves once, remembered; awaiting again is instant
6
7// Across workers with SharedArrayBuffer, Atomics.wait IS a parking
8// primitive and DOES require the loop:
9// returns 'ok' | 'not-equal' | 'timed-out' and may wake without a notify
10while (Atomics.load(buf, 0) === 0) {
11 Atomics.wait(buf, 0, 0) // must re-check; 'ok' is not a promise
12}
13// Atomics.wait is unavailable on the main browser thread (it would block
14// the UI) - worker threads only.

Two regimes again. Promises carry state, so no loop. Atomics.wait is a genuine parking primitive on shared memory and needs the same while as any condition variable.

TypeScriptNODE.JS
1// Same runtime, but types can encode the rule so it cannot be skipped.
2// Shape the API as "wait until predicate" rather than "wait":
3
4async function waitUntil(
5 cond: AsyncCondition,
6 predicate: () => boolean,
7): Promise<void> {
8 while (!predicate()) await cond.wait() // the loop lives HERE, once
9}
10
11// Callers cannot write the `if` version, because there is no bare wait()
12// exposed on the type they are given:
13interface AsyncCondition { waitUntil(p: () => boolean): Promise<void> }
14
15await queueCond.waitUntil(() => items.length > 0 || closed)
16
17// Compare: Java's Object.wait() documents spurious wakeup and offers no
18// predicate overload at all, which is why the `if` bug is most common there.

The types cannot detect a spurious wakeup, but they can remove the API that lets you ignore one. Exposing only a predicate-taking waitUntil makes the if bug unrepresentable.

What actually differs
  • POSIX, C++, Java and Python all explicitly permit spurious wakeup; C++ and Python ship predicate-taking overloads that write the loop for you, and Java does not — which is why the bug is most common in Java code.
  • Even where spurious wakeup is impossible, notify_all with several waiters and barging arrivals produce the same requirement, so the loop is never optional.
  • Promises, futures, latches and semaphore permits carry state and are resolved or taken exactly once — these need no loop, and adding one is a sign the wrong primitive is in use.
  • Atomics.wait in JavaScript is a real parking primitive on shared memory and requires the loop, unlike every other JavaScript coordination construct.
  • The reviewable rule: parking primitives loop, value-carrying primitives do not.

Key points

  • wait may return without any notification. POSIX, C++, Java and Python all explicitly permit it.
  • Even without spurious wakeups the loop would still be required, because notify_all wakes several waiters for one item and a barging task can consume the state before you re-acquire the lock.
  • Reframe the wakeup: it is a hint to re-check, never a promise about the state. That framing makes all four wake reasons the same case.
  • Prefer the predicate-taking overload — cv.wait(lk, pred) in C++, cond.wait_for(pred) in Python — which is defined as the loop.
  • Java offers no predicate overload on Object.wait, which is why the if bug is most common there.
  • The rule that generalises: parking primitives (condition variables, monitors, Atomics.wait) need a loop; value-carrying primitives (futures, latches, semaphore permits) do not.
  • The if version breaks the moment a second reason to wake is added, which is why it fails during maintenance even when it was correct when written.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • wait registers the task on the condition variable's wait set, releases the mutex and blocks.
  • The task may be made runnable by a notify, by a notify_all, or by the implementation itself — for example when a signal interrupts the underlying futex wait and the implementation chooses to return rather than restart.
  • Before wait returns, the mutex must be re-acquired, which can mean waiting behind the signaller and behind any task that was not waiting at all.
  • During that re-acquisition window, any other task holding the lock may change the state, including consuming the very item the notification was about.
  • Therefore the state on return is unknown, and the only sound action is to re-evaluate the predicate with the lock held and either proceed or wait again.
Interleavings that matter
  • Genuine notify, one waiter, nothing else runs: predicate true on return, proceed. The case the if version handles.
  • notify_all with four waiters and one item: one proceeds, three find the predicate false and wait again. With if, three exceptions fire simultaneously.
  • Barging: a fresh consumer takes the item between the notify and the woken task re-acquiring the lock. Predicate false on return, in an implementation that never wakes spuriously.
  • Truly spurious: wait returns with no notification sent at all. Predicate false, handled by the same loop with no special case.
  • Two wake reasons: an item arrives and, separately, the queue closes. With while and a compound predicate, both are distinguished after the loop; with if, adding the second reason silently breaks the first.
  • A timed wait expiring: returns with the predicate false and must be treated exactly like any other wake — re-check, then decide whether to retry or give up.
What it guarantees — and does not
  • wait guarantees the mutex is held when it returns. It guarantees nothing about the predicate.
  • It does NOT guarantee that a notification was sent. Spurious return is permitted by every major specification.
  • It does NOT guarantee that a notification aimed at you was not consumed by someone else first.
  • notify_all guarantees every current waiter becomes runnable. It does not guarantee any of them can proceed.
  • A timed wait guarantees a bounded return; it does not guarantee the predicate is true on return, whether it timed out or not.
  • The predicate overload guarantees the loop is written correctly, which is the only guarantee in this lesson you actually get for free.
Where contention appears
  • notify_all with N waiters produces N wakeups and N lock acquisitions, of which typically one succeeds. That is a small thundering herd, and on a hot queue it is measurable. See Thundering Herd.
  • Prefer notify when exactly one waiter can proceed per state change, and separate condition variables per predicate so a wake reaches tasks that can actually make progress.
  • The re-check itself is cheap — a predicate evaluation under a lock already held — so the loop costs essentially nothing on the path where the predicate is true.
  • Spurious wakeups are rare enough in practice that their contention cost is irrelevant; the herd from notify_all is the cost that actually shows up in a profile.
How it fails
  • Proceeding on a false predicate — dequeue from an empty queue, read from an unready buffer, use a resource that has not been initialised.
  • Simultaneous identical exceptions across several waiters at the exact moment work arrives, which reads as data corruption and is not.
  • Maintenance breakage: an if version that was correct with one wake reason is silently broken when a shutdown signal is added.
  • Silent wrong results where the predicate guarded data validity rather than presence — the task proceeds with a partially initialised structure and no exception is raised at all.
  • Herd from notify_all used where notify was correct, wasting wakeups on every state change.
  • A timed wait treated as "the predicate is now true", which is the same bug with an extra return value.
When it helps
  • Always. The loop is the correct use of the primitive, costs one predicate evaluation, and makes every wake reason equivalent.
  • It future-proofs the code: adding a second reason to wake — shutdown, timeout, cancellation — requires no change to the waiting structure.
  • It removes an entire category of review discussion, because "is a spurious wakeup possible on this platform?" stops being a question anyone needs to answer.
When it hurts
  • It does not. The only cost is one predicate evaluation on a path that already holds the lock.
  • The related mistake worth avoiding is looping around a *value-carrying* primitive — awaiting a promise in a loop, or re-acquiring a semaphore permit you already hold — which signals that the wrong primitive is in use.
  • A loop with no timeout and no cancellation path can spin between spurious wakes indefinitely if the predicate never becomes true. That is not a spurious-wakeup problem; it is a missing deadline. See Timeouts.
How you would know
  • Count wakeups against successful predicate evaluations. A large ratio means notify_all is waking tasks that cannot proceed, and points at the primitive choice rather than at the loop.
  • Grep for wait() not immediately preceded by a while on the same predicate — this is a mechanical, high-yield static check and several linters implement it.
  • In Java specifically, review every Object.wait() call: with no predicate overload available, each one is a hand-written loop that may not exist.
  • Watch for exception clusters — several identical failures at the same instant, at the moment work arrived — which is the if-bug signature rather than a data problem.
Complexity it introduces
  • Essentially none. The loop is one keyword, and the predicate-taking overloads remove even that.
  • The real complexity is conceptual: understanding that a wakeup carries no information about the state, which is counter to how the API reads.
  • Compound predicates — items available *or* closed *or* cancelled — do add complexity after the loop, because each wake reason needs its own branch. That is inherent to having several reasons, not to the loop.
Simpler alternatives
  • Use the predicate-taking overload rather than hand-writing the loop: cv.wait(lk, pred) in C++, cond.wait_for(pred) in Python. Same semantics, no chance of writing if.
  • Use a value-carrying primitive where the semantics allow: a future, a latch or a semaphore permit is taken once and needs no re-check. See Futures & Promises and Latches & Countdowns.
  • Use a standard blocking queue or channel, which encapsulates the loop entirely. See Concurrent Queues and Channels.
  • Wrap your own condition API so only a predicate-taking waitUntil is exposed, making the if version unrepresentable at every call site.

wait() in an if, or wait() in a while

wait() in an if, or wait() in a while
A consumer waits for the buffer to be non-empty. The difference between the two spellings is one keyword, and it is the difference between correct and corrupt.
lock()
if (items == 0):         # checked once, before sleeping
    cond.wait(lock)      # releases the lock, sleeps, reacquires
take(item)               # <- assumes the predicate is still true
unlock()
Invariant · items >= 0 — a consumer only takes an item that exists.
#ProducerConsumer 1Consumer 2RuntimeState
1·lock(); if (items == 0) wait()··items=0 waiters=1
2··lock(); if (items == 0) wait()·items=0 waiters=2
3lock(); items = 1; notifyAll(); unlock()···items=1 waiters=0
4·proceed: take(item)··items=0 waiters=0
5··proceed: take(item)·items=-1 waiters=0
✕ two consumers took one item — items = -1
notifyAll() woke both consumers, but only one item exists. C1 relocked first and took it; C2 then ran straight past a condition that had already become false again, because `if` checked the predicate once — before it slept. A wakeup is not a promise that the predicate is true; it is only a hint that it may be worth looking. The rule has no exceptions worth remembering: always wait in a loop over the predicate, and hold the lock while checking it. The condition variable carries no state and remembers no notifications — a notify() sent while nobody is waiting is simply lost, which is why the shared predicate, not the signal, is the source of truth.
SIMPLIFIEDA schedule the runtime is allowed to produce, not one it must. That is the point: this failure is legal and rare.

Producers, a bounded queue, consumers

Producers, a bounded queue, consumers
The queue is the only thing they share, and its capacity is the only thing standing between a mismatched pair of rates and unbounded memory. Watch who ends up waiting on whom.
1/40 · tick 1
queue depth0 · 0 of 4 slots used
Producer 1
blocked on put()
Producer 2
blocked on put()
Consumer 1
consuming
consuming
consuming
consuming
consuming
consuming
consuming
consuming
consuming
runningreadywaitingblockedidle40 ticks × 10 ms
offered rate
100/s
consumer capacity
33/s
consumers busy
over 100%
wait for a consumer
unbounded
At tick 1, 1 consumer is parked inside take() with an empty queue — waiting on a producer, holding a thread and doing nothing. Structurally, 2 producers offer 100/s against a consumer capacity of 33/s. The queue cannot absorb a permanent surplus, only a temporary one — so the bound does its job by blocking producers, which is exactly the point: the capacity converts an unbounded memory problem into a bounded latency problem, and pushes the imbalance back up the pipeline where somebody can see it. Two failure modes hide in this diagram and neither is a deadlock: a blocked producer is backpressure working, and an idle consumer is capacity you paid for and did not use. The queue does not create throughput — the slower side always sets it. What the queue buys is tolerance for jitter, and what it costs is latency (an item sits in it) and memory (it holds items), which is why the capacity is a design decision and not a default.
SIMULATEDTicks are 10 ms of model time with fixed service times; the steady-state wait comes from the M/M/c approximation in the engine. Real arrivals are bursty and real service times vary, so real queues form earlier and deeper than this.

The producer is faster than the consumer

The producer is faster than the consumer
A permanent surplus has to go somewhere: into memory, into a blocked producer, or into the bin. The one option that does not exist is for it to go nowhere.
1/60 · t+1s
queue memory25 MB
queue depth400 · no ceiling declared
queue latency
400 ms
delivered
1,000
items lost
0
status
alive, 20s left
t+0sunbounded queue · producer 1,400/s · consumer 1,000/s
t+20squeue holds 8,000 items · 500 MB · GC pauses lengthening, latency climbing
t+21sOOM: 512 MB exhausted. Process killed. Everything still in the queue is gone, and the producer finally stops — because it died too.
400 items per second have nowhere to go, so they go into the heap: 25 MB at t+1s, and the OOM killer arrives at t+21s. Notice what this system does *not* have: it does not have "no backpressure". It has backpressure with a 512 MB buffer and a process death as its signalling mechanism. Every queue is bounded — an unbounded queue is one whose bound is the machine, whose signal is a crash, and whose overflow policy is "lose everything, including the items that were already safely queued". Whichever you pick, pick it on purpose and export the counter that proves which one fired.
SIMULATEDFixed rates over 60 model seconds, 64 KB per item, 512 MB before the process dies. Real heaps degrade before they die — GC pressure and swapping make the last few seconds far worse than this straight line suggests.

What people believe, and what is true

Claim

Spurious wakeups are a theoretical concern that never happens in practice.

Reality

They are permitted and do occur, but that is beside the point: notify_all with several waiters and barging arrivals produce a false predicate on return in every implementation. The loop is required regardless.

Claim

If I use notify instead of notify_all, if is safe.

Reality

A barging task can still take the item between the notify and your re-acquisition of the lock, and a spurious return is still permitted. Neither depends on which notify you used.

Claim

The loop is defensive programming.

Reality

It is the specified way to use the primitive. wait is defined to return when it *may* be worth re-checking, not when the condition holds.

Claim

My if version has run in production for two years without a problem.

Reality

It has one waiter, one wake reason and light load. The first of those to change breaks it, and the failure will look like data corruption rather than a concurrency bug.

Go deeper

Overview

Waking up does not mean the thing you waited for is true. Check again, in a loop.

Practical

Write while (!predicate) wait(), or better, use the predicate-taking overload your language provides. Never if. Never assume a return value means the predicate holds.

Advanced

The rule generalises past spurious wakeups: parking primitives require a loop because the state can change between the wake and the re-acquisition; value-carrying primitives (futures, latches, permits) do not, because they hold what you waited for. If you find yourself looping around a future, you have the wrong primitive.

Internals

Spurious returns arise from the implementation: a signal interrupting the underlying futex wait, or a wake-all used internally to avoid tracking exactly which waiter to target. Permitting them lets the fast path avoid bookkeeping, and since a correct waiter must re-check anyway — because of barging — the specification gives up nothing by allowing it.

Apply it