Condition Variables & Thread Synchronization Coding Puzzles
In modern C++, we never guess what the compiler does. We look directly at byte offsets, CPU registers, and object ownership lifetimes.
Because cv.wait() must atomically release the mutex before placing the calling thread to sleep, and re-acquire the mutex before returning to the caller. std::lock_guard is a strictly scoped, immovable wrapper that cannot be manually locked or unlocked after construction. std::unique_lock implements the BasicLockable concept with explicit .lock() and .unlock() member functions, allowing the condition variable implementation to manage lock state dynamically.
A spurious wakeup occurs when a thread waiting on a condition variable wakes up without any explicit notify call. OS kernels permit spurious wakeups because eliminating them on multi-processor architectures would require expensive cross-core synchronization primitives that slow down the fast path of thread scheduling. Additionally, Unix signal deliveries (EINTR) can interrupt kernel sleeps. To safeguard against spurious wakeups, always verify the condition in a while loop or pass a predicate lambda to cv.wait(lock, predicate).
When a thread calls cv.notify_one() while still holding the mutex associated with the condition variable, the operating system awakens the sleeping thread. The awakened thread immediately attempts to re-acquire the mutex, finds it still locked by the notifying thread, and is immediately forced back to sleep on the mutex's wait queue. This causes two redundant kernel context switches. To avoid it, unlock the mutex before calling notify_one() or notify_all() whenever the protected state update is complete.
Interviewer's lens
Condition variable live-coding puzzles (such as Print Odd-Even in order, 3-thread turn taking, and Bounded Blocking Queue) are tier-1 interview favorites because they test your control over thread sleep and wakeup mechanics.
What separates candidates:
- Junior instinct: Writes CPU-burning busy-wait loops (
while (!ready) {}), tests conditions with anifstatement instead of a loop beforewait()leaving code vulnerable to spurious wakeups, or tries to passstd::lock_guardwithout understanding whystd::unique_lockis mandatory. - Senior mastery: Fluently walks through the 3-step atomic unlock-sleep-relock protocol, avoids the 'hurry up and wait' pessimization by unlocking before notifying, explains kernel futex wait-queues and wait-morphing, and writes bug-free producer-consumer queues on the whiteboard.
Under the hood & memory layout
Think of a condition variable as a waiting room with a doorbell:
- Predicate Check & Atomic Sleep: Inside
cv.wait(ulock, pred), the thread tests the predicate while holdingulock. If false, the OS kernel atomically unlocks the mutex and places the thread on the condition variable's wait queue in a single kernel transition. This prevents notifying threads from signaling in the gap (Lost Wakeup). - Signaling & Wait Morphing: When another thread calls
cv.notify_one(), the kernel wakes a waiting thread. High-performance kernels optimize this via wait morphingβtransferring the waiter directly from the CV wait-queue to the mutex wait-queue without an unnecessary context switch. - Wakeup & Re-Lock: Before
wait()returns to the caller, the thread strictly re-acquires thestd::unique_lock(blocking if another thread holds the mutex) and re-tests the predicate to guarantee safety against spurious wakeups.
Contains a native pthread_cond_t (sequence counter, waiter count, and kernel wait-queue pointer). Requires an external std::mutex to guard predicate evaluation.
OS kernel doubly-linked list of sleeping task_struct pointers. Threads placed here are taken off CPU run queues and consume 0% CPU.
Condition variables cannot use std::lock_guard because the CV runtime must repeatedly call .unlock() before sleeping and .lock() upon waking.
Evaluated while holding the lock. If true, cv.wait() returns immediately without sleeping. If false, atomic sleep is initiated.
Core concepts & syntax traps
Why Busy-Waiting Burns 100% CPU and How Condition Variables Suspend Threads
Why Busy-Waiting Burns 100% CPU and How Condition Variables Suspend Threads
Waiting for another thread can be handled in two fundamentally different ways:
- Busy-Waiting (The Anti-Pattern): Writing
while (!ready) {}executes billions of instructions per second in a tight loop. It pegs a CPU core at 100% utilization, causes thermal throttling, and starves other worker threads of CPU execution time. - Event-Driven Sleep (The Production Pattern): Calling
cv.wait()commands the OS kernel scheduler to immediately deschedule the calling thread and add it to a suspended list. - Zero CPU Overhead: While asleep, the thread consumes 0% CPU. It only wakes up when another thread updates the state and sends an explicit notification.
// ANTI-PATTERN: Burns 100% of a CPU core!
while (!ready) {
// spins aggressively
}
// PRODUCTION PATTERN: 0% CPU usage while waiting
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [&]() { return ready; }); // sleeps in kernel until signaledNever busy-wait on a shared condition; use std::condition_variable to put threads to sleep with zero CPU overhead.
Under the Hood of cv.wait: The 3-Step Atomic Unlock-Sleep-Relock Dance
Under the Hood of cv.wait: The 3-Step Atomic Unlock-Sleep-Relock Dance
Calling cv.wait(ulock, predicate) executes a mandatory 3-step synchronization protocol:
- Evaluate Predicate: While holding
ulock, the lambda condition is executed. If it returnstrue,cv.wait()returns immediately without sleeping. - Atomic Unlock & Suspend: If
false, the runtime atomically unlocksulockand places the thread onto the OS wait queue. This step must be atomic: if unlocking and sleeping were separate, a notifying thread could signal between them, causing a permanent Lost Wakeup! - Awaken & Re-lock: Once notified, the thread wakes up and must re-acquire
ulockbefore doing anything else. If another thread currently holds the mutex, the awakened thread blocks on the mutex until it is released, then re-evaluates the predicate.
// The standard overload:
cv.wait(lock, [&]{ return dataReady; });
// Is equivalent under the hood to:
while (!dataReady) {
cv.wait(lock); // 1. unlocks lock, 2. sleeps, 3. re-locks on wake
}cv.wait atomically unlocks the mutex and sleeps, then re-acquires the mutex before returning.
Spurious Wakeups & The Mandatory Predicate Loop (Why 'if' is a Bug)
Spurious Wakeups & The Mandatory Predicate Loop (Why 'if' is a Bug)
A waiting thread can wake up from cv.wait() even when no thread called notify(). This is known as a Spurious Wakeup.
Why spurious wakeups happen:
- OS kernel context switches, signal interruptions (
EINTR), or internal multi-core race resolutions can prematurely unblock sleeping threads. - In multi-consumer systems, another thread might wake up first and claim the work item before this thread even gets scheduled.
Why if (!ready) cv.wait() is a bug: If a thread wakes spuriously and you checked with if, execution falls through immediately, processing uninitialized or empty data!
The Invariant: Always check the condition inside a while loop, or use the standard predicate overload cv.wait(lock, [&]{ return ready; }) which loops automatically until the condition is genuinely true.
// FATAL BUG: vulnerable to spurious wakeups!
if (!hasData) {
cv.wait(lock);
}
processData(); // CRASH: hasData might still be false!
// CORRECT: predicate loop is mandatory
while (!hasData) {
cv.wait(lock);
}
// Or idiomatic modern C++:
cv.wait(lock, [&]() { return hasData; });Always verify condition variables in a while-loop or use the predicate overload; spurious wakeups make 'if' statements buggy.
notify_one() vs notify_all() and the 'Hurry Up and Wait' Pessimization
notify_one() vs notify_all() and the 'Hurry Up and Wait' Pessimization
Choosing the right notification method and timing determines queue throughput:
cv.notify_one(): Wakes exactly one waiting thread. Ideal when only a single worker can consume the event (e.g., pushing 1 item to a queue).cv.notify_all(): Broadcasts to all waiting threads. Mandatory when a state change allows multiple threads to proceed (e.g., queue shutdown or multi-stage turn-taking).- The 'Hurry Up and Wait' Trap: If you call
cv.notify_one()while still holding the mutex, the sleeping thread wakes up in the kernel, tries to re-acquire the mutex, finds it locked by you, and is immediately put back to sleep on the mutex queue!
The Performance Fix: Update shared state inside the lock, then explicitly call lock.unlock() before calling cv.notify_one() whenever feasible.
// SUBOPTIMAL: Woken thread immediately collides with held lock
{
std::lock_guard<std::mutex> lock(mtx);
dataReady = true;
cv.notify_one(); // Worker wakes up but cannot acquire mtx yet!
}
// OPTIMAL: Unlock before notifying
{
std::unique_lock<std::mutex> lock(mtx);
dataReady = true;
lock.unlock(); // Release lock first
cv.notify_one(); // Worker wakes up and acquires mtx immediately!
}Unlock the mutex before calling notify_one() when feasible to avoid the 'hurry up and wait' context-switch penalty.
The Canonical Bounded Blocking Queue Pattern (Dual Condition Variables)
The Canonical Bounded Blocking Queue Pattern (Dual Condition Variables)
A production-grade Bounded Blocking Queue coordinates producers and consumers using two distinct condition variables:
notFull_(Producer Gate):- Producers wait here when
queue.size() == capacity. - Consumers call
notFull_.notify_one()after popping an item to let a blocked producer push.
- Producers wait here when
notEmpty_(Consumer Gate):- Consumers wait here when
queue.empty(). - Producers call
notEmpty_.notify_one()after pushing an item to alert a sleeping worker.
- Consumers wait here when
Why two CVs are mandatory: If you use a single condition variable with notify_one(), popping an item might wake another consumer instead of a blocked producer, causing a lost wakeup deadlock. With dual CVs, signals are targeted with 100% accuracy.
std::condition_variable notFull_;
std::condition_variable notEmpty_;
void push(T item) {
std::unique_lock<std::mutex> lock(mtx_);
notFull_.wait(lock, [&]{ return queue_.size() < capacity_; });
queue_.push_back(std::move(item));
lock.unlock();
notEmpty_.notify_one(); // Alert consumer
}Use dual condition variables (notFull and notEmpty) for bounded queues to signal the exact target consumer/producer class.
Unlock Live Implementation & Interview Scenarios
The C++23 production code, live coding steps, scenario twist solutions, and common UB traps are part of Brute2Best Pro. Module 1 is completely free.
First anchor question per pattern, Part 1 LLD & Module 1 C++ basics are 100% Free forever.