C++ / a working model

99 / 163   ·   C++20   ·   13 min

A condition variable waits for state, not a single notification

Keep this sentence

A one-shot computation handoff must simultaneously prove that state is visible, that the wait is not lost, and that the worker thread will not outlive the object's lifetime. Protect the predicate with a mutex and use C++20 jthread to manage termination; whether the notification arrives early or late does not change the answer.

In this lesson
  1. Leave the completion fact in the state
  2. Waking up is not the same as the condition holding
  3. Prove wait-for-completion and object lifetime separately
  4. Example
  5. Exercise
READING EVIDENCE / Full text read

C++ Concurrency in Action

Fully read chapters 1–10 and appendices A–D of the 2012 first edition (main-text pages 1–486); additionally read the visible portions of chapters 1 and 2 from the unauthenticated second-edition preview, without claiming to have finished the second edition. The first-edition public PDF's web conversion breaks at page 194, so a private local text was used to continue reading through the end of appendix D; table of contents and index are not counted as main text. Historical APIs and examples are not the C++20 specification; this article does not copy lock-free container implementations.

Edition, actual reading range, and original sources →

Leave the completion fact in the state

The main thread needs to wait for the worker thread to compute the answer. The most fragile design is to sleep for a short time and then assume the computation has finished: the scheduler has not promised that the worker thread will get a chance to run in that interval. Another misconception is treating notify_one as an accumulable message; a notification when there is no waiter does not leave a ticket.

We put the answer and ready under the protection of the same mutex. The worker thread first writes the answer, then sets ready to true, then unlocks and notifies. The main thread uses wait with a predicate to check ready. If the notification has already happened, the predicate is already true and there is no need to block at all; if it is not yet complete, wait atomically releases the lock and enters the wait. The completion fact lives in ready, not in the number of notifications.

Waking up is not the same as the condition holding

Waits allow spurious wakeups; even if someone did notify, one cannot deduce from that that the business condition we want already holds. Therefore one should use wait(lock, predicate), which repeatedly checks the predicate while holding the lock until the result is true before returning. The lock here must be a unique_lock that can be temporarily released and reacquired, not a lock_guard that only releases on scope exit.

The main thread still holds the same lock when it reads answer. The worker thread's unlock and the subsequent acquisition of that lock establish a synchronization relationship that makes the write visible to the read. ready need not be changed to atomic: as long as all accesses are always protected by the same mutex, an ordinary bool is sufficient. Moving the predicate outside the lock or mixing in another lock breaks this proof and cannot be fixed by extra notifies.

Prove wait-for-completion and object lifetime separately

A condition variable solves state handoff; it does not automatically manage the worker thread. The example constructs the jthread after the synchronization objects, so reverse-order destruction first waits for the worker thread to finish, then destroys the mutex and condition variable it refers to. Using an explicit inner scope can also make this relationship more conspicuous; one must not detach the thread and then let references point at a stack that has already left.

The original book's first edition explained lifetimes with C++11 thread and a homemade RAII guard; this example switches to C++20 jthread. This is not forced cancellation: after the destructor issues a stop request it still waits for the thread to return on its own. The sample task is a small computation that is guaranteed to finish and needs no shutdown protocol; if it were changed to an infinite consumer queue, one would have to design a closed state, wakeup, and exit condition. Repeatedly running and getting the correct output can only help observation; correctness ultimately comes from state and synchronization relationships.

Pitfalls

  • notify_one is not a persistent message; do not use a single bare wait in place of a state predicate.
  • jthread will not forcibly terminate an infinite loop; automatic join can still wait forever, and one should not destroy it while the waiter holds a lock the worker thread needs.

Run an example

Minimum C++20 · complete program · Download .cpp

#include <cassert>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>

int main() {
    std::mutex mutex;
    std::condition_variable changed;
    bool ready = false;
    int answer = 0;
    {
        std::jthread worker([&] {
            const int computed = 6 * 7;
            {
                std::lock_guard lock(mutex);
                answer = computed;
                ready = true;
            }
            changed.notify_one();
        });
        std::unique_lock lock(mutex);
        changed.wait(lock, [&] { return ready; });
        assert(answer == 42);
        std::cout << answer << "\n";
    }
}

Compile locally

g++ -std=c++20 -Wall -Wextra -Wpedantic -pthread books-cpp-concurrency-in-action.cpp -o example && ./example

Expected result

42

CHECK YOUR UNDERSTANDING

Close the answer. Explain it.

The worker thread has already finished and notified before the main thread calls wait; why can the main thread still exit? What if only the notification is kept and ready is deleted?

Show a reference answer

The predicate overload of wait first checks ready while holding the lock. The worker thread has already set it true, so after the main thread acquires the lock it sees that write and therefore returns immediately. Without a persistent predicate, the earlier notification is not cached and a later bare wait can block forever; even if it happens to wake spuriously, that does not prove the answer has been written.

Check the sources

Drafts and official chapters change. The version mark is only the example’s minimum.

Back to the catalog