Skip to Content
Course content

109: Condition Variables

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I see this mistake almost every time I review code from developers moving into multi-threaded C++: they treat std::condition_variable::notify_one() like a reliable postal service. They assume that if they call notify, the waiting thread will wake up, see that the work is ready, and get to it. They often write code like this: cv.wait(lock); without any surrounding loop or predicate.

Thinking notify_one() is a Guarantee

The misconception is that the notification itself carries the "truth" of the condition. In reality, a condition variable is just a signaling mechanism, not a state container. If you just call wait(), your thread might wake up even if nobody called notify—this is called a "spurious wakeup." It's a quirk of how threading APIs are implemented across different operating systems. Even worse, if you have two threads waiting and one wakes up, it might grab the data and finish the job before the second thread even fully wakes up. If that second thread doesn't check the state again, it'll try to process data that isn't there, and your program will crash.

The Necessity of the Predicate Loop

To handle this, we never wait in a vacuum. We always wait on a predicate—a boolean expression that defines exactly what "ready" looks like. C++ gives us a convenient overload for wait() that takes a lambda. Under the hood, this lambda is wrapped in a while loop. I always tell my juniors: if you aren't checking the condition in a loop, you're just gambling with your stability.

Let's look at a concrete example. Imagine we're building a background job queue for a game engine. We have a producer pushing tasks and a worker thread waiting to execute them.

#include <mutex>
#include <condition_variable>
#include <queue>
#include <string>
#include <iostream>

class JobQueue {
    std::queue<std::string> tasks;
    std::mutex mtx;
    std::condition_variable cv;

public:
    void push_job(const std::string& job) {
        {
            std::lock_guard<std::mutex> lock(mtx);
            tasks.push(job);
        }
        // Notify after releasing the lock to avoid "hurry up and wait" 
        // where the woken thread immediately blocks on the mutex we still hold.
        cv.notify_one();
    }

    std::string pop_job() {
        std::unique_lock<std::mutex> lock(mtx);
        
        // This is the critical part. The lambda [this]{ return !tasks.empty(); }
        // ensures that if we wake up spuriously, we check the queue and go back
        // to sleep if it's actually still empty.
        cv.wait(lock, [this] { return !tasks.empty(); });

        std::string job = tasks.front();
        tasks.pop();
        return job;
    }
};

Notice that I used std::unique_lock in pop_job. You can't use std::lock_guard here because cv.wait() needs to be able to unlock the mutex while it sleeps and relock it when it wakes up. lock_guard is too simple for that; it only unlocks when it goes out of scope.

Managing the Wake-up Storm

One final tip: choose between notify_one() and notify_all() carefully. If you have ten worker threads waiting on a queue and you push one single job, notify_one() is efficient. If you use notify_all(), you wake up all ten threads, but nine of them will immediately realize the queue is empty (thanks to that predicate loop we wrote) and go back to sleep. This is called a "thundering herd" problem, and it can absolutely tank your performance in high-throughput systems.




📋 Practical Task

Build a Thread-Safe Print Spooler

Your task is to implement a PrintSpooler class that manages a set of documents waiting to be printed. Multiple "Application" threads will be submitting documents, and one "Printer" thread will be processing them one by one.

Requirements:

  • Implement a submit_document(std::string doc) method that adds a document to a internal queue and notifies the printer.
  • Implement a process_next_document() method that blocks the printer thread until a document is available.
  • You must use std::condition_variable with a predicate lambda to prevent spurious wakeups.
  • Ensure the process_next_document() method returns the document string once it has been successfully popped from the queue.
  • Use std::unique_lock and std::mutex to ensure thread safety.

Test your implementation by spawning three threads that each submit five documents, and one worker thread that prints "Printing: [doc name]" in a loop until a specific "SHUTDOWN" signal is received.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.