How std::condition_variable Coordinates C++ Threads
In modern C++, std::condition_variable is a
synchronization primitive that enables multiple threads to communicate
by waiting for and signaling specific state changes. This article
explains the coordination mechanism of
std::condition_variable, how it interacts with
std::unique_lock to prevent busy-waiting and race
conditions, how notifications operate, and how to handle spurious
wakeups effectively.
The Purpose of std::condition_variable
When multiple threads share data, one thread often needs to pause execution until another thread modifies the shared state. Without a condition variable, a waiting thread must repeatedly poll the state inside a loop, consuming significant CPU resources (known as busy-waiting).
std::condition_variable eliminates busy-waiting by
allowing a thread to put itself to sleep while waiting for a condition
to become true. The operating system will only wake the sleeping thread
when another thread explicitly signals that the state has changed.
The Coordination Workflow
Thread coordination using std::condition_variable relies
on three components:
- A boolean condition representing the shared state.
- A
std::mutexthat protects that shared state. - A
std::condition_variableinstance that manages the sleep and wake signals.
The coordination process follows these distinct phases:
1. Waiting on a Condition
To wait, a consumer thread acquires ownership of the mutex using a
std::unique_lock<std::mutex>:
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return ready; });The wait() function performs three actions
automatically:
- It evaluates the predicate (e.g.,
ready). If true, execution continues immediately without blocking. - If false, it atomically releases the mutex and suspends the calling thread into a blocked state.
- When the thread is notified and wakes up, it reacquires the mutex before checking the predicate again.
A std::unique_lock is strictly required instead of
std::lock_guard because wait() must be able to
unlock and re-lock the mutex dynamically.
2. Modifying State and Notifying
When a producer thread updates the shared state, it follows these steps:
{
std::lock_guard<std::mutex> lock(mtx);
ready = true; // Modify the shared state
}
cv.notify_one(); // or cv.notify_all();- The producer acquires the mutex to ensure exclusive access to the shared state.
- It modifies the shared state (setting
ready = true). - It releases the mutex (either implicitly at scope exit or explicitly).
- It calls
notify_one()to wake up a single waiting thread, ornotify_all()to wake all waiting threads.
Signaling after releasing the lock avoids a scenario called "pessimization," where a signaled thread wakes up only to immediately block again waiting for the producer to release the mutex.
Handling Spurious Wakeups
A waiting thread can occasionally wake up even if no thread has
called notify_one() or notify_all(). This
phenomenon is known as a spurious wakeup and is caused
by underlying operating system kernel behavior.
Because of spurious wakeups, a condition variable must never be used
without verifying the underlying condition. Modern C++ addresses this by
providing a two-argument overload of wait() that takes a
predicate function:
cv.wait(lock, [] { return is_ready; });This overload is equivalent to:
while (!is_ready) {
cv.wait(lock);
}The loop ensures that if the thread wakes up spuriously and
is_ready is still false, it automatically
re-enters the waiting state.
Choosing Between notify_one() and notify_all()
notify_one(): Unblocks one of the threads currently waiting on the condition variable. If multiple threads are waiting, the OS scheduler decides which one wakes up. This is ideal for work-queue models where only one worker should process each newly available task.notify_all(): Unblocks all waiting threads. This is necessary when multiple threads are waiting for a global event, such as a shutdown signal, or when the state change could satisfy conditions for multiple distinct threads.