Skip to content
VLSI Mentor

C++ Part I · Module 26 · Concurrency Fundamentals

Condition Variables

Every primitive so far has protected data. This one does something different: it lets a thread wait for a condition to become true, and lets another thread say that it has. The need is universal — a worker waiting for a job, a consumer waiting for a queue to be non-empty, a main thread waiting for a shutdown to complete — and the naive answer, a loop that keeps checking, is wrong in a way that is easy to measure: the same two-hundred-millisecond wait cost 209 milliseconds of CPU when spun and 0.1 milliseconds with a condition variable, a factor of about two thousand, on a machine where that core had better things to do. The harder half is the contract. A condition variable is the one facility in this module whose correct use looks like ceremony — a mutex you already hold, a unique_lock rather than a lock_guard, and a predicate you must pass even though the API lets you omit it — and every part of that ceremony exists because of a specific failure. The most important of them, the lost wake-up, is measured here: a wait without a predicate missed a notification that had already been sent, in all eleven runs.

Advanced20 min readC++Part IFundamentalsConcurrency

Part I · Module 26 · Chapter 26.6 · Condition Variables

1. The Problem It Solves

A mutex answers "may I touch this?" A condition variable answers a different question: "has something become true yet, and if not, wake me when it has."

The shape is always the same. One thread needs a state change that another thread will make, and it does not know when:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#include <condition_variable>
#include <mutex>

std::mutex              m;
std::condition_variable cv;
bool                    ready = false;      // guarded by m

// the waiting thread
{
    std::unique_lock<std::mutex> lk{m};
    cv.wait(lk, [&]{ return ready; });      // blocks until ready is true
}

// the notifying thread
{
    std::lock_guard<std::mutex> g{m};
    ready = true;                           // change the state UNDER the lock
}
cv.notify_one();                            // then announce it

Three objects, always together: the mutex, the condition variable, and the state they are about. The condition variable itself holds no state at all — it is purely a waiting room, and the thing being waited for is the ordinary variable ready, protected by the ordinary mutex m. Getting this wrong by expecting the condition variable to remember something is the root of half the bugs in this chapter.

What wait does, in order: it checks the predicate; if true, it returns immediately and you still hold the lock. If false, it releases the mutex and blocks. When notified it reacquires the mutex, re-checks the predicate, and either returns or blocks again. The release-and-reacquire is why it needs a unique_lock, and §3 is about why each of those steps is there.

2. What Spinning Costs

The alternative is to keep looking, which is called spinning or busy-waiting:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::atomic<bool> ready{false};
while (!ready.load()) { }                   // correct, and expensive

That is not wrong — ready is atomic, so there is no data race, and it does terminate. Here is what it costs. Three ways of waiting 200 ms for a flag, in one process, fifteen runs:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   spin on atomic       wall  210.0 ms   CPU   210.0 ms
   condition_variable   wall  205.8 ms   CPU     0.1 ms
   atomic wait/notify   wall  210.1 ms   CPU     0.1 ms
how it waitswall, medianCPU, medianCPU range
spin on an atomic210.0 ms209.1 ms121.5–210.1
std::condition_variable210.0 ms0.1 ms0.0–0.2
std::atomic<bool>::wait209.9 ms0.1 ms0.0–0.3

Wall-clock time is the same for all three, and that is the trap. Measured with a stopwatch, spinning looks free — it waited exactly as long as it had to. The difference is invisible unless you measure CPU time — which is the mirror image of Chapter 25.8 §6's warning that a CPU-time profile cannot see the time a thread spends blocked. Wall time and CPU time answer different questions, and a wait is exactly where they diverge.

CPU time differs by about a factor of two thousand. The spinning thread burned 209 ms of processor time to accomplish nothing, while the blocked thread burned a tenth of a millisecond. On an eight-core machine that is one core of eight consumed for the duration, taken from whatever else was running — including, often, the very thread you are waiting for.

The spin's CPU figure also varies much more (121.5–210.1 ms against 0.0–0.2 ms), and the reason is instructive: under load the spinner is descheduled, so it burns less CPU precisely when the machine is busiest. A spin that looks cheap in a measurement may simply have been starved.

One more observation, reported because it happened and not because it is tidy. One of the fifteen spin runs took 34,260 ms of wall time — thirty-four seconds for a two-hundred-millisecond wait — while using no more CPU than the others, meaning it spent that time descheduled rather than spinning. I also saw a comparable outlier on the condition-variable arm once, in a process whose parent shell had been killed. The machine was carrying a load average of 13 to 16 on eight cores throughout. Because I saw one outlier on each arm, I am reporting them as symptoms of an overloaded machine rather than as a property that distinguishes the two waits. The CPU comparison is the robust finding; the latency tail is a reason to be suspicious of any timing taken on a busy box, which is Chapter 26.4 §4's lesson in a different costume.

Spinning has one legitimate use: when the wait is expected to be shorter than the cost of blocking and waking, which is tens of nanoseconds, a few iterations of spin before blocking can win. That is what a good mutex implementation does internally, and it is not something to hand-roll — Chapter 26.5 §9 measured where hand-rolled spinning leads.

3. The Wait Contract

This is the section to read twice. wait has two overloads, and one of them is a bug in almost every program that uses it.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cv.wait(lk);                         // overload 1: "wake me when notified"
cv.wait(lk, predicate);              // overload 2: "wake me when this is true"

They sound equivalent. They are not, because a notification is an event, not a state: if nobody is waiting when notify_one is called, the notification is simply gone. Nothing records it. So overload 1 can be told about a change it then waits forever for.

Here is that failure, measured. The program sets the state, notifies, and then waits — the order you get whenever the notifier happens to run first:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
B. the lost wake-up: notify BEFORE the waiter waits
   wait() with NO predicate : returned by notification? NO -- timed out
   wait(lk, predicate)      : returned after 0.000 ms (state was already true)

Eleven of eleven runs, identical. The no-predicate wait never returned; it had to be rescued by a 200 ms timeout. The predicate version returned in 0.000 ms, because the first thing wait(lk, predicate) does is evaluate the predicate — it found the state already true and never blocked at all.

That is the whole argument, and it generalises past condition variables: ask about state, not about events. The predicate turns "has someone told me?" into "is it true?", and the second question has an answer whether or not you were listening when it changed.

Three more parts of the contract, each with a reason.

You must hold the mutex, and it must be a unique_lock. wait has to release the mutex while blocked — otherwise the notifier could never take it to change the state, which would be a one-mutex deadlock of the Chapter 26.5 §8 kind — and reacquire it before returning. std::lock_guard cannot unlock, so it cannot be used; this is the case Chapter 26.4 §5 listed as unique_lock's main reason to exist.

The predicate is evaluated with the mutex held, so it may safely read the guarded state. That is what makes [&]{ return ready; } correct rather than a race.

Change the state under the lock, then notify. Notifying may be done inside or outside the critical section — both are correct — but modifying must be inside it. If you set ready = true without the lock, a waiter can evaluate its predicate in between and miss it, and you have reconstructed the lost wake-up with a predicate in place.

The rule that follows is short enough to memorise: always use the predicate overload. Treat cv.wait(lk) with no predicate as a defect, the same way Chapter 26.4 §2 treats a bare unlock().

4. Spurious Wake-ups, and Why They Are Free to Handle

wait is permitted to return without any notification at all. This is not a defect in the implementation; the standard allows it, because preventing it would cost more than tolerating it on the platforms where it happens.

If you used the predicate overload, you have already handled it: wait(lk, pred) is specified to behave as

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
while (!pred())
    wait(lk);

so a spurious return re-evaluates the predicate, finds it false, and goes back to sleep. You will never see it.

If you used the bare overload, every spurious wake-up is a bug in your program, and you now have two unrelated failure modes — missed notifications and phantom ones — both fixed by the same one-line change. This is the second independent reason the predicate is mandatory, and the fact that one fix handles both is a good sign you are on the right side of the design.

5. notify_one Against notify_all

callwakesuse it when
notify_oneone waiting thread, unspecified whichany one waiter can handle the change — a job queue
notify_allevery waiting threadthe change matters to all of them, or they wait on different predicates

The failure to understand is specific: notify_one is wrong when the waiters are waiting for different things. If one thread waits for !queue.empty() and another for shuttingDown, a notify_one after a shutdown may wake the queue waiter, whose predicate is false, so it goes back to sleep — and the notification is consumed. The thread that needed it never hears it, and you have a lost wake-up again, through a different door.

Two safe rules: use notify_one when every waiter waits on the same predicate and any one of them can proceed; use notify_all whenever the predicates differ, and always for shutdown. notify_all with many waiters causes a thundering herd — all of them wake, all contend for the mutex, all but one go back to sleep — which costs real time at scale but is never incorrect. Start correct.

6. The Producer–Consumer Queue

This is the structure that justifies the chapter. It is also, as written below, complete — including the shutdown that most versions omit:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
template <typename T>
class BlockingQueue {
public:
    void push(T value)
    {
        {
            std::lock_guard<std::mutex> g{mutex_};
            queue_.push_back(std::move(value));
        }
        cv_.notify_one();                   // one item, one waiter can take it
    }

    // returns false once the queue is closed AND drained
    bool pop(T& out)
    {
        std::unique_lock<std::mutex> lk{mutex_};
        cv_.wait(lk, [&]{ return !queue_.empty() || closed_; });
        if (queue_.empty()) return false;   // closed and drained
        out = std::move(queue_.front());
        queue_.pop_front();
        return true;
    }

    void close()
    {
        {
            std::lock_guard<std::mutex> g{mutex_};
            closed_ = true;
        }
        cv_.notify_all();                   // EVERY waiter must learn this
    }

private:
    std::mutex              mutex_;
    std::condition_variable cv_;
    std::deque<T>           queue_;
    bool                    closed_ = false;
};

Six decisions in there are worth naming.

The predicate has two clauses. !queue_.empty() || closed_ is what lets the same wait serve both normal operation and shutdown. A predicate with only the first clause is the version that hangs on exit.

pop returns false only when the queue is closed and empty. Closing must not discard queued work; the consumer drains first. Getting this backwards loses the last items on every shutdown.

push notifies outside the critical section and close notifies after releasing too. Both are correct either way; doing it outside avoids waking a thread that will immediately block on the mutex you still hold.

push uses notify_one, close uses notify_all. One new item can be handled by any one consumer; a closure must reach all of them. This is §5's rule applied.

The mutex is private and so is every field — Chapter 26.4 §6's callout, which is what makes "mutex_ guards all of this" enforceable rather than hopeful.

There is no unlock() anywhere, and pop's unique_lock exists only because wait requires it.

7. Waiting With a Deadline

Both overloads have timed versions, and the predicate forms are again the ones to use:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// returns false on timeout, true if the predicate became true
const bool ok = cv.wait_for(lk, std::chrono::seconds(5), [&]{ return ready; });

// the same against an absolute time point
const bool ok2 = cv.wait_until(lk, deadline, [&]{ return ready; });

The predicate form returns a bool — the predicate's final value — so you never have to work out whether a std::cv_status meant a real wake-up. The non-predicate forms return std::cv_status::timeout or no_timeout, and they inherit every problem in §3 and §4.

Two cautions.

wait_for restarts its timeout on a spurious wake-up, so the total time waited may exceed the duration. If the deadline is genuine, use wait_until with an absolute time point, which cannot drift.

A timeout is a policy decision, not an error handler. It is right for a watchdog, a health check, or a shutdown that must not hang — the same ranking Chapter 26.5 §9 gave for try_lock_for. A timeout added because a wait "sometimes doesn't wake up" is a lost wake-up being papered over, and the paper is thin: the measured failure in §3 would have been hidden, not fixed, by exactly that timeout.

8. The C++20 Alternatives, All of Which Work Here

C++20 added four facilities that cover common waits with less ceremony. All four compile and run on this toolchain, which is worth stating because availability varies:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
counting_semaphore OK
latch OK
barrier OK
atomic wait/notify OK
facilitywhat it isuse it for
std::atomic<T>::wait / notify_oneblock until the value changesa single flag — the simplest correct wait there is
std::latcha one-shot countdownwait for N tasks to finish, once
std::barriera reusable rendezvousN threads looping in phases
std::counting_semaphoreN permitsa resource pool, a bounded queue's free slots

The atomic wait is the one that most often replaces a condition variable outright, and §2 measured that it costs the same 0.1 ms of CPU — it blocks properly rather than spinning. For a single boolean flag it needs no mutex and no predicate:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::atomic<bool> ready{false};
ready.wait(false);                   // block while the value is still false
// ... elsewhere:
ready.store(true); ready.notify_one();

It is immune to the lost wake-up, because wait(false) begins by comparing the current value — the same "ask about state" move the predicate makes. But it only waits on one atomic. The moment the condition involves two variables, a container's emptiness, or any compound invariant, you are back to a mutex and a condition variable, which is why §6's queue is not written with atomics.

std::latch deserves a specific mention because it replaces the commonest hand-rolled condition variable of all — "wait for the workers to finish" — with three lines and no predicate to get wrong:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::latch done{workers};
// each worker: done.count_down();
done.wait();                         // main thread

9. The Table

the questionthe answermeasured
what does a condition variable holdno state — it is a waiting room§1
what are the three objectsthe mutex, the cv, and the guarded state§1
what does spinning cost209.1 ms of CPU for a 200 ms wait§2
what does blocking cost0.1 ms — about 2000× less§2
why is wall time no helpall three arms took ~210 ms§2
which wait overloadalways the predicate form§3
what happens without itnotification sent before the wait was missed in 11 of 11 runs§3
why a unique_lockwait must release and reacquire the mutex§3
must the state change be lockedyes — notifying need not be§3
spurious wake-upshandled for free by the predicate§4
notify_one or notify_allall when predicates differ, and always for shutdown§5
what breaks in real codeshutdown — a one-clause predicate never exits§6 callout
the single-flag alternativestd::atomic<T>::wait, 0.1 ms CPU, no mutex§8
wait for N tasksstd::latch§8

10. What to Actually Do

  • Never busy-wait. 209 ms of CPU against 0.1 ms, and wall time will not show you the difference.
  • Always pass the predicate to wait, wait_for and wait_until. Treat the bare overloads as defects.
  • Keep the three objects together — mutex, condition variable, guarded state — in one class with everything private.
  • Change the state under the lock. Notify inside or outside; modify always inside.
  • Write the predicate as a list of reasons to wake, with shutdown as a clause from the first draft.
  • notify_all whenever waiters have different predicates, and for every close or cancel.
  • Return false from a pop only when closed and drained, so shutdown does not discard work.
  • Use wait_until with an absolute deadline if the deadline is real; wait_for restarts on spurious wake-ups.
  • Reach for std::atomic<T>::wait for a single flag and std::latch for "wait for N things" — both work here and both have less to get wrong.
  • Do not add a timeout to fix a wait that "sometimes misses". That is a lost wake-up, and §3 shows a timeout hides it.

11. Mistakes Worth Making Once

cv.wait(lk) with no predicate. Measured: the notification arrived first and the wait never returned, 11 of 11.

Expecting the condition variable to remember the notification. It holds no state; §1.

Setting the flag without the lock, then notifying. Reconstructs the lost wake-up even with a predicate.

std::lock_guard with wait. It will not compile, and the reason is the point: wait must unlock.

A one-clause predicate. Works perfectly until the program tries to exit, then hangs in join.

notify_one to shut down four consumers. One exits, three block forever.

A spin loop because "the wait is short". It is short in wall time and enormous in CPU.

Measuring a wait with a stopwatch. All three arms in §2 look identical by wall clock.

A timeout added to make an intermittent hang go away. You have hidden a missed notification.

wait_for in a loop for a hard deadline. Each spurious wake-up restarts the clock; use wait_until.

12. Interview Relevance

The frozen curriculum marks this High, and the question is usually "how do you make one thread wait for another?" The complete answer is a mutex, a condition variable, and a predicate, and the quickest way to show you have used them is to explain why the predicate is not optional: a notification is an event rather than a state, so if the notifier runs first the notification is gone, and a bare wait blocks forever. Saying that you measured it — the no-predicate wait missed an already-sent notification in eleven of eleven runs, while the predicate version returned in 0.000 ms because it checks the state before blocking — turns a memorised rule into something you clearly believe.

The second point worth volunteering is the cost of the obvious alternative: the same 200 ms wait took 209 ms of CPU as a spin and 0.1 ms blocked, with identical wall-clock time, which is a neat illustration that wall time cannot see wasted work.

If the conversation goes deeper, shutdown is the strongest ground. Most candidates' blocking queues hang on exit, and the reason is that the predicate was written as "is there work?" rather than "is there any reason to wake?" — so the fix is a second clause plus notify_all, not a new mechanism. Mentioning that notify_one is actively wrong when waiters wait on different predicates, and that spurious wake-ups come free once the predicate is there, covers the follow-ups before they are asked.

13. What You Should Be Able to Say Now

  • That a condition variable holds no state, and that correct use always involves three things: a mutex, the condition variable, and the guarded state.
  • That spinning cost 209.1 ms of CPU [121.5–210.1] for a 200 ms wait while condition_variable and atomic::wait cost 0.1 ms — about 2000× — with the same wall-clock time.
  • That the spin's CPU figure falls under load because the spinner gets descheduled, so a cheap-looking spin may just have been starved.
  • That wait must be given a predicate, because a notification is an event and is lost if nobody is waiting — 11 of 11 runs missed it without one.
  • That wait(lk, pred) behaves as while (!pred()) wait(lk);, which is also why spurious wake-ups cost you nothing.
  • That wait needs a unique_lock because it releases and reacquires the mutex, and that the state change must happen under the lock.
  • That notify_all is required whenever waiters wait on different predicates, and for every shutdown.
  • That shutdown is where these break: a one-clause predicate passes every functional test and hangs at exit, and the fix is to write the predicate as a list of reasons to wake.
  • That std::atomic<T>::wait, std::latch, std::barrier and std::counting_semaphore all work on this toolchain, and that the atomic wait is the simplest correct wait for a single flag.

14. Exercises

Exercise 1 — the cost. Reproduce §2's three arms, measuring CPU time with getrusage as well as wall time. Confirm the wall times are indistinguishable.

Exercise 2 — the lost wake-up. Write the §3 experiment: set the state, notify, then wait, with a 200 ms rescue timeout. Run it with and without the predicate.

Exercise 3 — the compile error. Try to pass a std::lock_guard to wait and read the error. Explain it in one sentence.

Exercise 4 — the queue. Implement §6's BlockingQueue, run four consumers and one producer, then close it. Confirm every consumer exits and no queued item is lost.

Exercise 5 — break the shutdown twice. Remove the || closed_ clause and confirm join hangs. Restore it, change notify_all to notify_one, and count how many consumers exit.

Exercise 6 — the unlocked flag. Set closed_ without taking the mutex and run the close in a loop until a consumer hangs.

Exercise 7 — the C++20 versions. Rewrite the single-flag wait with std::atomic<bool>::wait and the "wait for N workers" case with std::latch. Compare the line counts and the number of things that can go wrong.

15. Summary

  • A condition variable lets a thread wait for a state change and be woken when it happens. It stores no state itself; it is always used with a mutex and an ordinary guarded variable.
  • Spinning is the wrong default, measured: the same 200 ms wait cost 209.1 ms of CPU spun and 0.1 ms blocked — roughly 2000× — with identical wall-clock times, so a stopwatch cannot see the difference.
  • Always pass the predicate. A notification is an event, not a state: with the state set and the notification sent before the wait, the bare wait never returned in 11 of 11 runs, while wait(lk, predicate) returned in 0.000 ms.
  • The predicate also absorbs spurious wake-ups for free, because wait(lk, pred) is specified as while (!pred()) wait(lk);.
  • wait requires a unique_lock because it releases the mutex while blocked and reacquires it before returning; the state change itself must always be made under the lock.
  • notify_all is mandatory when waiters wait on different predicates, and for every shutdown — notify_one can be consumed by a thread whose predicate is false.
  • Shutdown is where real code breaks. A predicate that asks "is there work?" passes every functional test and hangs at exit; write it as "is there any reason to wake?" with a closed clause from the start, and drain before reporting closure.
  • Use wait_until for a real deadline, and never add a timeout to silence an intermittent hang — that is a lost wake-up in disguise.
  • C++20's atomic::wait, latch, barrier and counting_semaphore all work here; the atomic wait blocks just as cheaply and is the simplest correct answer for a single flag.

Chapter 26.7 is that single flag, and why a plain bool is not one.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.