Concurrency
Part 3 of 6 · ConcurrencyCondition Variables: Mesa vs Hoare
A condition variable lets a thread sleep until a predicate on shared state becomes true, always paired with the mutex that guards that state. Under Hoare semantics, signal hands the mutex straight to the woken waiter, so the predicate is guaranteed true when it runs. Under Mesa semantics (pthreads, Java, C++, Go, Rust, Python), signal is only a hint: the signaler keeps running, the waiter re-acquires the mutex later, and by then the predicate may be false again. So always wait in a while loop, never an if.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Lesson map
Condition Variables: Mesa vs Hoare
The consumer waits on the mutex, the producer signals as a hint and keeps the lock, and the waiter rechecks in a while loop after it reacquires.
Architecture. Consumer Holding. Mutex Held. Producer Idle. Consumer 2 Idle
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB consumer["Consumer Holding"] mutex["Mutex Held"] producer["Producer Idle"] consumer_2["Consumer 2 Idle"] consumer -->|Lock| mutex consumer -->|Wait| mutex producer -->|Push| mutex producer -->|Signal| consumer producer -->|Unlock| mutex consumer -->|Reacquire| mutex consumer -->|Pop| mutex consumer -->|Skips if| mutex consumer_2 -->|Steals| mutex consumer -->|Empty pop| consumer_2 producer -->|Hoare| consumer consumer -->|Stale if| producer
Overview
This is one of the most common concurrency questions because the bug is invisible in a simple test. Code that waits with if works until a second consumer, a broadcast, or a spurious wakeup shows up in production.
The same protocol (predicate, mutex, while, signal or broadcast, shutdown flag) underlies bounded queues, thread pools, and connection pools. A lost or stolen wakeup there is a hung worker or a crash on an empty queue.
By the end you should be able to:
- Write the Mesa wait loop from memory
- Contrast Hoare's immediate handoff with Mesa's hint
- Choose
signalorbroadcastfrom the predicates on the CV - Draw a lost wakeup when unlock and sleep are two steps
- Implement a bounded queue with one mutex and two condition variables
while on Mesa vs if copied from a Hoare textbook
Prefer
while the predicate is false, wait
Mesa, a stolen wakeup, a broadcast, and a spurious return all look the same when wait returns: the predicate might be false. The loop re-checks under the mutex. while is also correct under Hoare, so it is always the safe choice.
- wait releases the mutex atomically, so a producer can make the predicate true.
- Under Mesa the signaler keeps running and holding the mutex. The waiter runs later.
- One CV per predicate (not_empty, not_full) plus signal, or one CV plus broadcast.
Alternative
if the predicate is false, wait
That if is the Hoare assumption: the waiter runs immediately and the predicate still holds. On a Mesa runtime another thread can take the item first, and the woken thread pops an empty queue.
- A broadcast can wake several waiters for one item.
- POSIX allows a return from wait with no matching signal.
- Building Hoare-style direct handoff everywhere adds a context switch on every signal.
Happy path: Mesa wait and signal
The seven steps match Diagram 1 below. Signal is a hint. The while loop is what makes the pop safe.
- 1
The consumer locks and sees an empty queue
The predicate lives in the queue, not in the condition variable. - 2
The consumer waits on the CV
wait releases the mutex and puts the consumer to sleep in one atomic step, so no signal can slip in between the check and the sleep. - 3
The producer locks and pushes an item
The state change happens under the mutex. - 4
The producer signals the CV
Under Mesa this is only a hint. The consumer becomes runnable, and the producer still holds the mutex. - 5
The producer unlocks
The mutex is free for the waiter to re-acquire. - 6
The consumer wakes and re-acquires the mutex
wait does not return until the consumer holds the mutex again. - 7
The while loop re-checks, then pops
The queue is not empty, so the consumer pops. If another thread had taken the item first, the loop would wait again.
Diagrams - step by step
Three small diagrams for condition variables under Mesa and Hoare semantics. Step numbers in the labels give the animation order.
Diagram 1 - Happy path: Mesa wait and signal on a queue
Sequence
- 1
Consumer → Mutex
Step 1 lock - queue is empty
- 2
Consumer → Mutex
Step 2 wait on cv - unlock and sleep atomically
- 3
Producer → Mutex
Step 3 lock and push an item
- 4
Producer → Consumer
Step 4 signal cv - only a hint, producer keeps the lock
- 5
Producer → Mutex
Step 5 unlock
- 6
Consumer → Mutex
Step 6 wake up and reacquire the mutex
- 7
Consumer → Consumer
Step 7 while loop rechecks - queue not empty, pop
Under Mesa semantics, signal just moves the waiter to runnable; the signaler keeps running and holding the mutex. The waiter reacquires the mutex later and must recheck the predicate before acting. Waiting releases the mutex atomically, so a signal sent while the producer holds the mutex cannot be lost between check and sleep.
Diagram 2 - Failure path: if instead of while lets a stolen wakeup through
Sequence
- 1
Consumer 1 using if → Consumer 1 using if
Step 1 if queue empty then wait
- 2
Producer → Producer
Step 2 push one item and signal
- 3
Consumer 2 → Consumer 2
Step 3 C2 gets the mutex first and pops the item
- 4
Consumer 1 using if → Consumer 1 using if
Step 4 C1 reacquires and skips the recheck
- 5
Consumer 1 using if → Consumer 1 using if
Step 5 pop on an empty queue - crash or garbage
- 6
Consumer 1 using if
Fix - while queue empty wait, so C1 rechecks and sleeps again
Between the signal and the moment the woken thread gets the mutex, another thread can run and consume the item. Spurious wakeups, which POSIX and Java allow, cause the same problem with no signal at all. A while loop turns both into a harmless extra check.
Diagram 3 - Decision: Mesa or Hoare, and what goes wrong if you assume the other
Decisions
- 1
Step 1 Waiter needs a predicate to become true
- nextStep 2 Which semantics does the runtime give?
- ?
Step 2 Which semantics does the runtime give?
- pthreads, Java, C++ - MesaSignal is a hint and the waiter runs later
- Hoare monitorLock handed to the waiter immediately
- 3
Signal is a hint and the waiter runs later
- nextStep 3 Use while predicate false then wait
- 4
Lock handed to the waiter immediately
- nextStep 3 Predicate holds on wake, if is enough
- 5
Step 3 Use while predicate false then wait
- nextSafe under spurious and stolen wakeups
- 6
Step 3 Predicate holds on wake, if is enough
- Port this if-code to MesaActs on a stale predicate
- 7
Safe under spurious and stolen wakeups
- 8
Acts on a stale predicate
Hoare semantics hand the mutex straight to the woken waiter, so the predicate is still true when it runs. Almost every real runtime is Mesa, where that guarantee does not exist. A while loop is correct under both, so always write while.
The protocol
Lock the mutex. While the predicate is false, wait on the CV. wait atomically releases the mutex and sleeps, then re-acquires it before returning. Act on the state. Unlock.
The producer locks, changes the state, signals or broadcasts, and unlocks. The predicate lives in shared state, never in the CV. A CV has no memory, so a signal with no waiter is simply lost.
put(x):
lock
while full: not_full.wait
q.push(x)
not_empty.signal
unlock
get():
lock
while empty: not_empty.wait
x = q.pop()
not_full.signal
unlockAdd a closed flag checked in both loops, and broadcast it on shutdown, so no thread sleeps forever after the producers exit.
Hoare vs Mesa
| Hoare (1974) | Mesa (Lampson and Redell, 1980) | |
|---|---|---|
| On signal | The mutex and the CPU transfer to one waiter now. The signaler is suspended until the waiter leaves the monitor. | The waiter moves to the ready queue. The signaler continues. |
| Predicate after wait | Still true, in the theory, so if is enough. | Maybe false. while is mandatory. |
| Cost | Extra context switches on every signal. Harder to implement. Rare in practice. | Cheap signal. Broadcast is always safe, because every waiter re-checks. |
| Where you meet it | Textbook monitors. Direct-handoff queues. | pthreads, Java, C++ std::condition_variable, Go sync.Cond, Rust Condvar, Python threading.Condition. |
Between the signal and the moment the woken thread gets the mutex, another thread can run and consume the item (a stolen wakeup). A broadcast can wake several waiters for one item. The waiter can also wake with no signal at all. POSIX explicitly allows spurious wakeups, and Java, C++, and Go document the same rule. The while loop turns all three into an extra check.
while is also correct under Hoare, so write while even if you are reading a textbook that uses if.
signal vs broadcast
signal (notify, notify_one) wakes one waiter. It is the right call when every waiter waits for the same predicate and any one of them can make progress: one item, one consumer.
broadcast (notify_all) wakes all of them. You need it when waiters wait for different predicates on the same CV, or when one change can satisfy many (shutdown, a batch of capacity freed). Under Mesa, broadcast is always correct and just slower, because every waiter re-checks.
With one shared CV for both not_full and not_empty, signal can wake the wrong kind of thread and stall the system. Use two CVs, or broadcast. Two CVs plus signal is the classic bounded buffer. One CV plus broadcast is simpler to prove. Optimize later.
Signalling under the lock is the simplest to reason about. Signalling just after unlock can avoid a wasted wakeup where the waiter immediately blocks on the mutex the producer still holds. Both are correct on Mesa CVs as long as the state change itself happens under the mutex. Mutate, then signal. A signal that races the state change is a lost wakeup.
Lost wakeup
waiter: sees empty
producer: pushes, signals (nobody is sleeping yet)
waiter: sleeps (sleeps with the condition already true)wait takes the mutex so that "check the predicate, then sleep" is one step. If you unlock and then sleep as two steps, the producer can change the state and signal in the gap, and the wakeup is lost forever. You must hold the mutex when you wait. APIs that split unlock and sleep are incorrect unless they park on the value still matching (a futex, or Atomics.wait).
The mutex re-acquire is also the happens-before edge that makes the producer's writes visible. See happens-before. A bare atomic flag is not this protocol: atomics vs locks.
A timed-out wait is the same rule. Timeout, spurious return, or a real signal: re-check the predicate under the mutex. Do not treat timeout as "still false" without looking.
What goes wrong with the wrong pick
if-based code that would be correct under Hoare, running on a Mesa runtime, acts on a stale predicate. It pops an empty queue or overfills a bounded buffer.
Building Hoare-style direct handoff everywhere costs extra context switches and makes broadcast awkward. You still see that shape in synchronous queues and fair locks that hand ownership straight to the next waiter. The TypeScript HoareQueue below does that, so if is safe there: the woken waiter owns the item and nobody can barge.
Practical rule: assume Mesa, write while, and use one CV per predicate.
Pros and cons
| Approach | Pros | Cons |
|---|---|---|
| Mesa (signal is a hint) | Cheap signal, no forced context switch. Broadcast always safe. What every mainstream runtime provides. | Waiter must re-check in a while loop. Stolen and spurious wakeups. Some wakeups find nothing to do. |
| Hoare (immediate handoff) | Predicate guaranteed on wake, so if is enough. Easier proofs. | Extra context switches per signal. Signaler is suspended. Hard to implement and rare in practice. |
| Busy-wait or sleep-poll (no CV) | Trivial to write. No wait/signal protocol to learn. | Burns CPU, or adds latency equal to the poll interval. Still races without the mutex. |
Working Python
Run python3 cv_mesa.py (stdlib only, under a second). threading.Condition has Mesa semantics. notify_all wakes both consumers for one item. With if, the second consumer pops an empty deque. With while, it sees the empty queue and waits for the next item. Run it locally. The in-page sandbox has no operating-system threads.
"""Mesa condition variables: why the wait goes in a while loop, not an if.
Run: python3 cv_mesa.py (stdlib only, under 1 second)
threading.Condition has Mesa semantics (like pthreads, Java, Go, Rust): notify only marks
a waiter runnable; the notifier keeps the lock and the waiter must re-acquire it later.
By then the predicate may be false again (another consumer took the item, or a spurious wakeup).
"""
import collections
import threading
import time
class BoundedQueue:
def __init__(self, capacity: int, use_while: bool) -> None:
self.items: collections.deque = collections.deque()
self.capacity = capacity
self.use_while = use_while
self.lock = threading.Lock()
self.not_empty = threading.Condition(self.lock) # both CVs share one mutex
self.not_full = threading.Condition(self.lock)
def put(self, item) -> None:
with self.lock:
while len(self.items) >= self.capacity:
self.not_full.wait() # releases the lock and sleeps atomically
self.items.append(item)
self.not_empty.notify_all() # broadcast: every waiting consumer wakes
def get(self):
with self.lock:
if self.use_while:
while not self.items: # CORRECT: re-check after every wakeup
self.not_empty.wait()
else:
if not self.items: # BUG: assumes the item is still there
self.not_empty.wait()
item = self.items.popleft() # raises IndexError if the item was stolen
self.not_full.notify()
return item
def scenario(use_while: bool) -> None:
q = BoundedQueue(capacity=1, use_while=use_while)
results = []
def consumer(name: str) -> None:
try:
results.append((name, q.get()))
except IndexError:
results.append((name, "IndexError: woke up to an empty queue"))
c1 = threading.Thread(target=consumer, args=("c1",))
c2 = threading.Thread(target=consumer, args=("c2",))
c1.start(); c2.start()
time.sleep(0.1) # both consumers are now waiting
q.put("job-1") # one item, two woken consumers
time.sleep(0.1)
if use_while:
q.put("job-2") # the re-waiting consumer gets the next item
c1.join(1); c2.join(1)
label = "while" if use_while else "if "
print(f"{label}: {sorted(results)}")
if __name__ == "__main__":
scenario(use_while=False) # one consumer pops an empty deque
scenario(use_while=True) # the loser sees an empty queue and simply waits againSample run: with if, one consumer got job-1 and the other raised IndexError (it woke up to an empty queue after notify_all). With while, both consumers got a job, because the loser waited again. The same result held on 3 of 3 runs.
Working TypeScript
Run npx tsx cv_mesa.ts (no dependencies). MesaQueue only marks the waiter runnable, so another task can take the item before the waiter resumes. HoareQueue hands the item straight to the waiter. Run it locally so the turn-taking with setTimeout matches the sample.
// Mesa vs Hoare condition variables, shown with async tasks in Node.
// Run: npx tsx cv_mesa.ts (no dependencies)
// Mesa: notify() only makes the waiter runnable; it resumes later, after other code ran.
// Hoare: the signaler hands the state straight to the waiter, so the predicate still holds.
class MesaCondition { // async analogue of pthread_cond / Java wait()
private waiters: Array<() => void> = [];
wait(): Promise<void> { return new Promise((r) => this.waiters.push(r)); }
notify(): void { this.waiters.shift()?.(); } // a hint: "something changed, go re-check"
}
class MesaQueue<T> {
private items: T[] = [];
private notEmpty = new MesaCondition();
constructor(private useWhile: boolean) {}
put(x: T): void { this.items.push(x); this.notEmpty.notify(); }
async get(): Promise<T> {
if (this.useWhile) {
while (this.items.length === 0) await this.notEmpty.wait(); // CORRECT
} else {
if (this.items.length === 0) await this.notEmpty.wait(); // BUG under Mesa
}
const x = this.items.shift();
if (x === undefined) throw new Error("woke up to an empty queue (wakeup was stolen)");
return x;
}
}
class HoareQueue<T> { // Hoare-style: direct handoff to the waiter
private items: T[] = [];
private waiters: Array<(x: T) => void> = [];
put(x: T): void {
const w = this.waiters.shift();
if (w) w(x); else this.items.push(x); // the woken waiter owns the item, nobody can barge
}
get(): Promise<T> {
const x = this.items.shift();
return x !== undefined ? Promise.resolve(x) : new Promise((r) => this.waiters.push(r));
}
}
interface Q { put(x: string): void; get(): Promise<string>; }
async function scenario(label: string, q: Q): Promise<void> {
const log: string[] = [];
const a = q.get().then((x) => log.push(`A got ${x}`), (e: Error) => log.push(`A failed: ${e.message}`));
await Promise.resolve(); // A is now waiting
q.put("job-1"); // step 1: producer signals A
const b = q.get().then((x) => log.push(`B got ${x}`)); // step 2: B barges in before A resumes
await new Promise((r) => setTimeout(r, 0)); // step 3: A finally resumes
q.put("job-2"); // step 4: a second item so nobody waits forever
await Promise.all([a, b]);
console.log(`${label.padEnd(11)}: ${log.join(" | ")}`);
}
async function main(): Promise<void> {
await scenario("Mesa + if", new MesaQueue<string>(false));
await scenario("Mesa + while", new MesaQueue<string>(true));
await scenario("Hoare + if", new HoareQueue<string>());
}
main();Sample run: Mesa plus if let B take job-1 and A fail, because the wakeup was stolen. Mesa plus while let B take job-1 and A take job-2. Hoare plus if let A take job-1 and B take job-2, because direct handoff means nobody can barge in.
Interview Q&A
Why must you wait in a while loop?
Answer
Under Mesa semantics the predicate can be false when wait returns. Another thread may have consumed the state between the signal and your re-acquire, a broadcast may have woken several waiters for one item, or the wakeup may be spurious. The loop re-checks under the mutex. while is also correct under Hoare, so it is always the safe choice.
What is a spurious wakeup and why is it allowed?
Answer
A return from wait with no matching signal. POSIX permits it so implementations can be simpler and faster on multiprocessors. Java, C++, and Go document the same rule. The while loop makes it harmless.
Why does wait take the mutex as an argument?
Answer
To make "check the predicate, then sleep" atomic. If you unlocked and then slept in two steps, the producer could change the state and signal in the gap, and the wakeup would be lost forever (a lost wakeup).
Should the producer signal while holding the mutex or after unlocking?
Answer
Both are correct with Mesa CVs as long as the state change itself happens under the mutex. Signalling under the lock is the simplest to reason about. Signalling just after unlock can avoid a wasted wakeup where the waiter immediately blocks on the mutex the producer still holds.
signal or broadcast?
Answer
Use signal when every waiter waits for the same predicate and any one of them can make progress (one item, one consumer). Use broadcast when waiters have different predicates on the same CV, or when a change may satisfy many (shutdown, a batch of capacity freed). With one shared CV for not_full and not_empty, signal can wake the wrong kind of thread and stall the system. Use two CVs or broadcast.
Where do you still see Hoare-style semantics?
Answer
In textbook monitors and in direct-handoff designs: a synchronous queue or a fair lock that hands ownership straight to the next waiter, like HoareQueue in the TypeScript example. Mainstream runtimes (pthreads, Java, C++ std::condition_variable, Go sync.Cond, Rust Condvar, Python threading.Condition) are Mesa.
How would you implement a bounded blocking queue?
Answer
One mutex and two CVs, not_full and not_empty. put: lock; while full, wait on not_full; push; signal not_empty; unlock. get: lock; while empty, wait on not_empty; pop; signal not_full; unlock. Add a closed flag checked in both loops and broadcast it on shutdown so no thread sleeps forever.
Pitfalls
From memory: a bounded buffer, one mutex, two CVs, while loops, signal after the mutate. Then replace one while with if, wake two consumers for one item, and show the empty pop. If wait is not under the lock, start over.