condition_variable and synchronisation patterns
How do you put a thread to sleep until a condition holds, and then wake it? The condition_variable that removes busy waiting, why lost and spurious wakeups are prevented, and the producer-consumer work queue.
The previous part covered “how do you safely lock a shared resource?” This part’s subject is one step further on. How do you efficiently make a thread wait and then wake it? The core tool is condition_variable, and it is almost always paired with a mutex.
Why you need it: the waste of busy waiting
Trying to express “wait until another thread satisfies some condition” with a mutex alone gives you this.
while (!ready) {
// keep checking... spinning and burning CPU: busy waiting
}
It wastes CPU endlessly until the condition holds. What we want is “if the condition does not hold, put the thread to sleep and yield the CPU; when it does, have somebody wake it up”. That is what a condition_variable (cv for short) provides.
The basic shape: producer and consumer
The most typical form is one side creating the condition (the producer) and the other waiting on it (the consumer).
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
// the waiting side (consumer)
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return ready; }); // sleep until ready becomes true
// on waking, execution continues here (holding the lock again)
}
// the waking side (producer)
void producer() {
{
std::lock_guard<std::mutex> lock(mtx);
ready = true; // change the condition
}
cv.notify_one(); // wake one waiting thread
}
Everything from the previous part connects here.
Why it has to be unique_lock
cv.wait() internally releases the lock, sleeps, and takes the lock again on waking. For that unlock/lock cycle to be possible you need unique_lock, which carries state and can open and close flexibly. lock_guard cannot do this, so it cannot be used. That is the reason the previous part deferred “use unique_lock only with condition variables”.
Why it pairs with a mutex: lost wakeup
It is no accident that a cv requires a mutex. It has to prevent a race between “checking the condition” and “changing the condition and notifying”.
Suppose that in the instant between checking ready without a lock and entering wait, the producer finishes ready = true; notify(). The consumer misses the notify that just went by, and since nobody will wake it again it sleeps forever. This is a lost wakeup. The mutex removes that gap by binding “check the condition → enter the wait” into one atomic step.
Why you pass a predicate: spurious wakeup
Always use the cv.wait(lock, predicate) form. There are two reasons.
First, spurious wakeups. The OS really does sometimes wake a thread that nobody notified. Second, there is the timing problem of a notify arriving before the wait.
Given a predicate, wait re-checks the condition on every wake, proceeding only when it is genuinely true and going back to sleep otherwise. The predicate-less wait(lock) is fully exposed to these traps. The predicate version above is effectively shorthand for this safe re-checking loop:
while (!ready) {
cv.wait(lock);
}
notify_one and notify_all
notify_one() wakes exactly one waiter (which one is unspecified). notify_all() wakes every waiter.
Let me address a common misunderstanding here. notify_all does wake everybody, but not all of the woken threads go straight to work. They are filtered in two stages.
First, mutex contention. The woken threads have to retake the mutex as they come out of wait. Only one can take it at a time, so the rest block again and queue up. Everybody waking but ultimately passing through one at a time is called the thundering herd.
Second, re-checking the predicate. The thread that takes the lock re-evaluates the predicate. If the condition is already false (because an earlier thread took the only piece of work, say) it does not proceed and goes back to sleep.
So the observation “I called notify_all and only one ended up working” is accurate. The scope of the wake-up (everybody vs one) and the number of threads that actually do work (as many as satisfy the condition) are different things.
The rule of thumb: if the target is clearly one (one handler per item of work), notify_one is efficient. If several waiters’ conditions can change at once, or it is unclear who can proceed, notify_all is safer. When in doubt, secure correctness with notify_all and optimise to notify_one if a bottleneck shows up.
One caution: a notify is not stored. A thread that has not yet entered wait at the moment of the notify does not receive that signal. It will either enter wait later and await the next notify, or pass straight through wait if the predicate is already true.
A practical example: a work queue
Assembling the pieces above gives a thread-safe work queue.
#include <queue>
#include <mutex>
#include <condition_variable>
std::queue<int> tasks;
std::mutex mtx;
std::condition_variable cv;
void push(int task) {
{
std::lock_guard<std::mutex> lock(mtx);
tasks.push(task);
}
cv.notify_one(); // wake one waiting worker
}
int pop() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return !tasks.empty(); }); // wait while the queue is empty
int task = tasks.front();
tasks.pop();
return task;
}
One worker per item of work is enough, so notify_one fits. Even with several workers waiting, the one that wakes takes the work and the rest find the predicate false again and go back to sleep.
Simpler alternatives
A cv is powerful but fiddly and full of traps. Depending on the situation there are more concise tools.
For one-shot result delivery, std::future/std::promise or std::async is far simpler. They are high-level wrappers that specialise the cv pattern of “wait until a result is ready”.
#include <future>
std::future<int> fut = std::async(std::launch::async, []{
return heavyComputation();
});
int result = fut.get(); // waits until ready, then receives the result
Handling a cv directly shows its worth in structures that signal back and forth repeatedly, such as a work queue.
C++20: lighter synchronisation tools
C++20 introduced tools that express the same things more concisely, without the cv + mutex combination.
std::counting_semaphore / std::binary_semaphore: count-based signalling. Good for limits like “allow up to N at once”.
std::latch: a one-shot “wait until N have arrived”. Once opened it is not reused.
std::barrier: repeated phase synchronisation. Several threads wait for each other at the end of each phase and move to the next together.
There is also now the lightest way to implement conditional waiting, with a single atomic variable and no mutex.
#include <atomic>
std::atomic<bool> ready{false};
// waiting side
ready.wait(false); // wait while ready is false
// notifying side
ready.store(true);
ready.notify_one(); // wake a waiting thread
std::atomic’s wait/notify_one/notify_all achieve “wait and wake” with neither a mutex nor a cv. For a simple flag signal this is the lightest option.
Summary
- Purpose of a cv: eliminate busy waiting — sleep the thread until the condition holds, then wake it with a notify.
- unique_lock required: because
waitinternally releases and retakes the lock.lock_guardcannot do it. - Why it pairs with a mutex: it binds checking the condition and entering the wait atomically, preventing a lost wakeup.
- Predicate required: the
wait(lock, pred)form absorbs spurious wakeups and timing problems. - notify_one vs notify_all: one versus everybody, but the woken threads are filtered by mutex contention and by re-checking the predicate. The scope of the wake-up and the number that actually work are separate things.
- Alternatives: for one-shot cases,
future/promise/async. C++20’ssemaphore/latch/barrierandatomic::waitoffer more concise expression.
Across two parts we have covered locking with a mutex and waiting/waking with a cv. These are the two axes of C++ concurrency. One serialises access, the other carries signals between threads.