Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 19

Signaling with `std::condition_variable`

Recommended resources

Hello! Welcome to our next lesson on concurrency.

In our last session, we established how to protect shared data from corruption using std::mutex and the RAII-compliant std::lock_guard. This gave us the power to create thread-safe critical sections, ensuring that our concurrent code produces correct and predictable results.

However, this solution introduced a new, significant performance problem: busy-waiting. In a typical producer-consumer scenario, if the consumer thread finds the data queue empty, our current approach forces it into a tight loop: lock, check for data, unlock, repeat. This loop consumes 100% of a CPU core just to check for work, which is incredibly inefficient. For your goal of building a "zero-overhead" telemetry engine, such waste is unacceptable.

Today, we will solve this problem. Our learning outcome is to use std::condition_variable to signal between threads and prevent busy-waiting. This powerful tool allows a thread to go to sleep efficiently and wake up only when another thread notifies it that there is work to be done. This is the cornerstone of building efficient, event-driven concurrent systems.

The Problem: The Cost of Busy-Waiting

Let's first formalize the problem. Busy-waiting, also known as polling or spinning, occurs when a thread continuously uses CPU cycles to check for a condition to become true.

Three Ways For C++ Thread Synchronization

The article 'Three Ways For C++ Thread Synchronization' provides a great analogy for this problem. Imagine waiting for a package delivery.

Read the introductory part of Section 1, 'Periodically checking the condition - A naive approach'. Focus on the analogy of staring out the window for the delivery truck. This perfectly captures the inefficiency of busy-waiting.

As the article explains, staring out the window is a waste of your time. In software, this translates to wasted CPU cycles, increased power consumption, and potential performance degradation for other threads that could be doing useful work.

In our last lesson, we saw a producer-consumer example that, once protected by a mutex, suffered from this exact issue. Let's briefly revisit that.

C++ Multithreading Explained: Mutexes, Locks, and Queues

The video 'C++ Multithreading Explained' clearly demonstrates the performance cost of a busy-waiting consumer.

Watch the segment from 08:35 to 11:50. The narrator explains how the mutex-protected consumer, while correct, introduces a busy-wait loop. Pay attention to the discussion of high CPU usage and lock contention. This sets the stage for why we need a better solution.

This polling model—"Is there data yet? Is there data yet?"—is something we must eliminate to build a high-performance system. We need a way for the consumer to sleep until the producer signals that data is ready.


The Solution: std::condition_variable

A std::condition_variable is a synchronization primitive that allows threads to block until a specific condition is met. It works in conjunction with a std::mutex to coordinate this waiting and signaling.

Think of it as a doorbell for your package delivery. Instead of staring out the window, you go about your day. When the delivery person arrives (the producer), they ring the doorbell (the signal). Only then do you go to the door (wake up and process the data). This is an event-driven model.

To use a std::condition_variable, you need three things:

  1. A std::mutex to protect the shared data.
  2. The shared data itself, whose state constitutes the "condition" (e.g., a queue not being empty).
  3. A std::condition_variable object that threads can use to signal and wait.

The Mechanics: wait, notify_one, and std::unique_lock

Let's dive into the mechanics. The process involves two key operations: a producer thread notifies a consumer thread, which is waiting.

A waiting thread must be able to release its lock so the producer can access the shared data. std::lock_guard is too rigid for this; it only unlocks when it's destroyed. We need a more flexible lock: std::unique_lock.

std::unique_lock is also an RAII-style lock wrapper, but it allows for more advanced operations, like manually unlocking and re-locking. This flexibility is essential for std::condition_variable.

The following video provides a complete walkthrough of converting our busy-waiting consumer into an efficient, signaling-based one.

C++ Multithreading Explained: Mutexes, Locks, and Queues

This segment of 'C++ Multithreading Explained' is the core of our lesson. It introduces std::condition_variable and std::unique_lock to solve the busy-wait problem elegantly.

Watch from 11:50 to 18:34. Pay very close attention to these key concepts: std::unique_lock: Why it's used instead of std::lock_guard. cv.notify_one(): How the producer signals that data is ready. cv.wait(lock, predicate): This is the most important part. Understand the three things it does atomically: releases the lock, puts the thread to sleep, and re-acquires the lock upon waking. The Predicate: The crucial lambda function [] { return !q.empty(); } that checks the actual condition.

Let's summarize the magic behind cv.wait(lock, predicate):

  1. It first acquires the lock (which is passed in via the std::unique_lock object).
  2. It then evaluates the predicate (the lambda).
  3. If the predicate returns false (meaning the condition to proceed is not met, e.g., the queue is empty):
    • It atomically unlocks the mutex and puts the current thread to sleep. This is the crucial step that prevents busy-waiting and allows the producer to acquire the lock.
  4. When the thread is woken up by a notify_one() call from another thread:
    • It re-acquires the lock.
    • It checks the predicate again. If it's true, the wait() function returns, and the thread continues execution. If it's false, it goes back to sleep.

Handling Edge Cases: Spurious Wakeups and Lost Notifications

Why is the predicate so important? Why not just call cv.wait(lock) and assume that waking up means data is ready? There are two subtle but critical bugs this prevents.

  1. Spurious Wakeups: For complex reasons related to OS scheduler implementations, a waiting thread can occasionally wake up even if no notify_one() was called. If we didn't have the predicate, our thread would proceed to access the queue, assuming it has data when it might be empty, leading to a crash. The predicate acts as a guard, forcing the thread to re-check the condition and go back to sleep if it was a false alarm.

  2. Lost Notifications: What if the producer calls notify_one() before the consumer has called wait()? The notification is "lost," and if the consumer then calls wait() without a predicate, it might sleep forever. The predicate solves this. When the consumer eventually calls wait(lock, predicate), it will evaluate the predicate first. Since the producer already added data, the predicate will return true immediately, and the wait function won't put the thread to sleep at all.

These two issues are why you should always use the wait overload that takes a predicate.

Three Ways For C++ Thread Synchronization

These concepts are fundamental to using condition variables correctly. This article provides concise explanations of both.

Please read the subsections 'Lost Wakeups' and 'Spurious Wakeups' within Section 2 of the article. This will solidify your understanding of why the predicate is not optional, but essential.

Test your understanding!

You are given the following TaskProcessor class. The process_task method busy-waits until a task is available in the queue.

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

class TaskProcessor {
private:
    std::queue<std::string> task_queue;
    std::mutex mtx;

public:
    void add_task(const std::string& task) {
        std::lock_guard<std::mutex> guard(mtx);
        task_queue.push(task);
        std::cout << "Added task: " << task << std::endl;
    }

    void process_task() {
        while (true) {
            std::this_thread::sleep_for(std::chrono::milliseconds(50)); // to prevent 100% CPU
            std::lock_guard<std::mutex> guard(mtx);
            if (!task_queue.empty()) {
                std::string task = task_queue.front();
                task_queue.pop();
                std::cout << "Processing task: " << task << std::endl;
                // In a real app, we would break or return here,
                // but for the example, we keep processing.
            }
        }
    }
};

Your task is to modify this class to use std::condition_variable to eliminate the busy-wait (and the inefficient sleep_for).

  1. Add the necessary member variables to the class.
  2. Modify add_task to notify waiting threads.
  3. Rewrite process_task to wait efficiently using a std::condition_variable.
Show answer

Here is the modified, efficient implementation:

#include <iostream>
#include <string>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable> // 1. Include the header

class TaskProcessor {
private:
    std::queue<std::string> task_queue;
    std::mutex mtx;
    std::condition_variable cv; // 1. Add std::condition_variable member

public:
    void add_task(const std::string& task) {
        { // Create a smaller scope for the lock guard
            std::lock_guard<std::mutex> guard(mtx);
            task_queue.push(task);
            std::cout << "Added task: " << task << std::endl;
        } // Lock is released here
        cv.notify_one(); // 2. Notify one waiting thread
    }

    void process_task() {
        while (true) {
            // 3. Use std::unique_lock for the condition variable
            std::unique_lock<std::mutex> lock(mtx);

            // 3. Wait until the queue is not empty.
            // The lambda is our predicate.
            cv.wait(lock, [this] { return !task_queue.empty(); });

            // At this point, the wait has returned, we hold the lock,
            // and the queue is guaranteed to have at least one item.
            std::string task = task_queue.front();
            task_queue.pop();
            lock.unlock(); // Unlock early since we are done with shared data

            std::cout << "Processing task: " << task << std::endl;
        }
    }
};

Key changes:

  1. Added a std::condition_variable cv; member.
  2. In add_task, after pushing the task and releasing the lock, cv.notify_one() is called to wake a consumer.
  3. In process_task, the while loop with sleep_for is replaced by a std::unique_lock and a call to cv.wait(). The wait is governed by a predicate [this]{ return !task_queue.empty(); }, which ensures it only proceeds when there's a task.

Conclusion

You have now mastered one of the most fundamental patterns in concurrent programming. By moving from a polling model to a signaling model, you can write systems that are not only correct but also highly efficient.

Key Takeaways:

  • Busy-waiting burns CPU cycles and is an inefficient way to wait for work.
  • std::condition_variable provides an event-driven mechanism for threads to signal each other.
  • Waiting on a condition variable requires a std::mutex and a std::unique_lock, which is more flexible than std::lock_guard.
  • The cv.wait(lock, predicate) function is the core of this pattern. It atomically puts a thread to sleep and releases the lock.
  • Always use a predicate with wait() to correctly handle spurious wakeups and lost notifications.

With mutexes for protection and condition variables for efficient waiting, you have the tools to build a robust and performant producer-consumer data queue—the very spine of the telemetry engine you are aiming to build.

In our next lesson, we will put this all together and implement a basic producer-consumer pattern using mutexes and condition variables, formalizing the roles of the producer and consumer threads in a complete program.