Concurrency Roadmap/Condition Variables & Monitors

Guarded Blocks & Spurious Wakeups

Waking up from wait() never guarantees your condition actually holds — spurious wakeups are permitted by spec, and even without them a third thread can beat you to the resource under Mesa-style scheduling — so every guarded wait re-checks its predicate in a while loop, never an if, and notify vs. notifyAll becomes a correctness decision, not just a performance one.

!!4/5Theory: 35m1 problems
Language-specific mechanics: Concurrency Language Manual — Condition Variables, Waiting & Signaling

The rule: always while, never if

Every guarded wait on a condition variable should look like this, in any language:

acquire(lock) while not predicate_holds(): wait(condition_variable) # atomically releases lock, blocks, reacquires lock on return # predicate_holds() is now guaranteed true do_the_work() release(lock)

Not this:

# WRONG in essentially every mainstream language acquire(lock) if not predicate_holds(): wait(condition_variable) do_the_work() # predicate might be false here! release(lock)

This subtopic is about the two independent reasons the while form is mandatory — one of them is a documented, spec-level guarantee you should be able to cite by name, and the other is a straightforward consequence of the Mesa-style scheduling covered in the previous subtopic. Interviewers who ask "why do we loop here?" are usually listening for both, not just one.

Reason 1: spurious wakeups are real and permitted by spec

A spurious wakeup is when wait() returns even though nobody called notify/signal for that condition variable — no state change happened, no other thread did anything relevant. This sounds like it shouldn't be allowed, but it explicitly is, by design, in essentially every mainstream threading spec:

  • POSIX (pthread_cond_wait): the standard explicitly permits implementations to occasionally wake a waiting thread without a corresponding signal, as a concession to how some platforms implement condition variables efficiently (e.g., to avoid extra bookkeeping when handling POSIX signals or multiprocessor cache effects).
  • Java (Object.wait() and java.util.concurrent.locks.Condition.await()): both are documented to allow "a thread to wake up without being notified, interrupted, or timing out." The Object.wait() Javadoc even calls this out by name and shows the while-loop pattern as the required fix, in the same paragraph.
  • Python (threading.Condition.wait()): implemented on top of the platform's native primitives, so it inherits this same allowance — Python's own documentation and standard idioms use the while form for exactly this reason.

Why would any implementation actually do this? A few reasons show up in practice: some OS-level condition variable implementations are built on lower-level signaling primitives (like POSIX signals) that can themselves misfire; some multiprocessor implementations trade a small chance of a spurious wakeup for cheaper, lock-free wake paths; and permitting it in the spec at all gives implementers room to optimize without being held to a stricter guarantee they'd otherwise have to pay for on every call. Spurious wakeups are rare in practice on any given platform, but "rare" is not "impossible," and correctness code cannot depend on probabilistic behavior — a guard that's wrong 1 time in a billion is still wrong.

Reason 2: even without spurious wakeups, the resource can be gone by the time you wake up

This is the reason that actually bites people in production, far more often than a genuine spurious wakeup ever will, and it follows directly from the Mesa-style signal-and-continue semantics from the previous subtopic. Walk through this timeline for a bounded buffer with a single notEmpty condition:

  1. Consumer thread C acquires the lock, sees the buffer is empty, calls wait() — releases the lock, blocks.
  2. Producer thread P acquires the lock, adds an item, calls notify() (or notifyAll()). C is moved from "blocked" to "ready to run," but does not run yet — under Mesa semantics, P still holds the lock and keeps executing.
  3. P finishes its critical section and releases the lock.
  4. Before the OS scheduler gets around to running C, a third thread — call it C2, maybe another consumer, maybe even a thread that never called wait() at all and is only now calling take() for the first time (this is sometimes called "barging") — acquires the freshly-released lock, sees the buffer is non-empty, and takes the item.
  5. C finally gets scheduled, reacquires the lock (which it's now free to do since C2 released it) , and returns from wait(). If C trusted an if instead of a while, it would now try to remove an item from what might be an empty buffer.

Nothing here required a "spurious" wakeup in the technical sense — every notify was legitimate, every waiter was woken for a real reason. The problem is purely the gap between being woken and actually resuming execution with the lock held, during which any other thread with access to the same lock can change the state out from under you. This is a direct, unavoidable consequence of signal-and-continue scheduling, not an edge case you can engineer away — which is why the while-loop guard isn't a defensive nicety, it's load-bearing correctness logic.

Guard loops with multiple predicates: the bounded buffer

The producer-consumer / bounded-buffer problem is the canonical vehicle for practicing this, because it has two independent guard conditions sharing the same protected state (size, capacity):

put(item): acquire(lock) while size == capacity: wait(notFull) # guard: buffer is full add item; size += 1 signal(notEmpty) # a consumer's predicate may now be true release(lock) take(): acquire(lock) while size == 0: wait(notEmpty) # guard: buffer is empty remove item; size -= 1 signal(notFull) # a producer's predicate may now be true release(lock)

Two things to notice, both callback to the previous subtopic. First, whether you can safely use signal() here instead of signalAll()/notifyAll() depends entirely on whether your language gives each predicate its own condition variable (as this pseudocode assumes, and as java.util.concurrent.locks.Condition or POSIX's "one pthread_cond_t per predicate" convention both allow) or forces everyone waiting on the object to share one wait-set (as Java's intrinsic synchronized/wait/notifyAll does) — in the latter case you must broadcast, because a notify() might wake a producer when only a consumer's predicate changed, and that producer will just re-check, find size == capacity still true, and go back to sleep while the consumer that could have proceeded never gets a chance. Second, the while guard is what makes this correct even when threads race on which one gets to run first after a signal — exactly the barging scenario walked through above.

This shape — one lock, one or more condition variables, each with its own while (!predicate) wait() guard — is the monitor pattern from the previous subtopic in its most practically useful form, and it's the pattern nearly every "implement a thread-safe bounded/blocking structure" interview question (see the problems below) is really testing.

Pitfalls and interview gotchas

  • Using if instead of while. The single most common bug in guarded-wait code, and the one interviewers most often probe for directly by asking "what if the OS wakes this thread spuriously?" or "what if two consumers were both waiting?"
  • Checking the condition without holding the lock. The predicate and the lock are inseparable — reading shared state to decide whether to wait, without holding the lock that protects that state, reintroduces the exact race wait()'s atomicity was designed to close.
  • Forgetting that notify/signal doesn't release the lock. In every mainstream API, the signaling thread keeps the lock until it explicitly releases it (falls out of the synchronized block, calls unlock(), etc.); the woken thread cannot actually resume — even after being marked ready — until the signaler lets go. Confusing "signaled" with "running now" is what leads people to (wrongly) assume Hoare semantics are in play.
  • Assuming a single condition variable is fine for genuinely different predicates, when your language actually supports splitting them (as in the Condition/pthread_cond_t-per-predicate style above) — needlessly broadcasting when you could signal precisely is a common performance smell in review, even though it isn't a correctness bug.
  • Deadlocking by waiting while holding a second, unrelated lock. wait() only releases the lock associated with that condition variable; if the waiting thread is also holding some other lock acquired earlier, that second lock stays held for the entire wait — a frequent source of surprise deadlocks in code that nests locking without thinking it through.
Reference implementations in:

Bounded buffer with two guard conditions (notFull / notEmpty)

Two predicates sharing the same protected state: whether you can use a targeted signal() or must broadcast depends on whether your language gives each predicate its own wait-set.

type BoundedBuffer struct { mu sync.Mutex notFull *sync.Cond notEmpty *sync.Cond items []any capacity int } func NewBoundedBuffer(capacity int) *BoundedBuffer { b := &BoundedBuffer{capacity: capacity} b.notFull = sync.NewCond(&b.mu) b.notEmpty = sync.NewCond(&b.mu) return b } func (b *BoundedBuffer) Put(item any) { b.mu.Lock() defer b.mu.Unlock() for len(b.items) == b.capacity { b.notFull.Wait() // guard: buffer is full } b.items = append(b.items, item) b.notEmpty.Signal() // exactly one consumer can make progress } func (b *BoundedBuffer) Take() any { b.mu.Lock() defer b.mu.Unlock() for len(b.items) == 0 { b.notEmpty.Wait() // guard: buffer is empty } item := b.items[0] b.items = b.items[1:] b.notFull.Signal() // exactly one producer can make progress return item }

Two sync.Cond values sharing one Mutex give each predicate its own wait-set, so a targeted Signal() is safe — same idea as Java's Condition. Loop on Wait(), never a bare if. Idiomatic Go still prefers make(chan T, capacity) for this problem; Cond is here because the topic is condition variables. Never copy a Cond.

Further Resources (Optional)

Practice Problems

Interview relevancy:!!!Critical — must-have!!Important — highly recommended!Good to have~Niche — rarely asked