Advanced 14 min

Condition Variables

Master efficient thread coordination using condition variables to signal between threads

Learn how to efficiently coordinate threads using condition variables, enabling threads to wait for specific conditions without busy-waiting.

A Simple Example

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

std::mutex queueMutex;
std::condition_variable cv;
std::queue<int> dataQueue;
bool finished{false};

void producer(int id) {
    for (int i{0}; i < 5; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));

        {
            std::lock_guard<std::mutex> lock{queueMutex};
            dataQueue.push(id * 10 + i);
            std::cout << "Producer " << id << " added: " << (id * 10 + i) << "\n";
        }

        cv.notify_one();  // Wake up one waiting consumer
    }
}

void consumer(int id) {
    while (true) {
        std::unique_lock<std::mutex> lock{queueMutex};

        // Wait until queue has data or production is finished
        cv.wait(lock, []{ return !dataQueue.empty() || finished; });

        if (dataQueue.empty() && finished) {
            break;
        }

        int value{dataQueue.front()};
        dataQueue.pop();
        lock.unlock();

        std::cout << "Consumer " << id << " got: " << value << "\n";
    }
}

int main() {
    std::thread prod1{producer, 1};
    std::thread prod2{producer, 2};
    std::thread cons1{consumer, 1};
    std::thread cons2{consumer, 2};

    prod1.join();
    prod2.join();

    {
        std::lock_guard<std::mutex> lock{queueMutex};
        finished = true;
    }
    cv.notify_all();

    cons1.join();
    cons2.join();

    return 0;
}

Breaking It Down

std::condition_variable basics

  • What it does: Allows threads to wait for a condition to become true
  • wait(): Releases lock and sleeps until notified, then reacquires lock
  • notify_one(): Wakes up one waiting thread
  • notify_all(): Wakes up all waiting threads

wait() with predicate

  • Syntax: cv.wait(lock, []{ return condition; })
  • Benefit: Handles spurious wakeups automatically
  • How it works: Checks condition in a loop, sleeping if false
  • Remember: Always use the predicate form to avoid spurious wakeups

std::unique_lock vs std::lock_guard

  • unique_lock: Can unlock/relock manually, required for condition_variable
  • lock_guard: Simple RAII, cannot be unlocked manually
  • Why unique_lock: wait() needs to unlock the mutex while sleeping
  • Remember: Use unique_lock with condition variables

Producer-consumer pattern

  • Producer: Adds items to queue, notifies consumers
  • Consumer: Waits for items, processes them
  • Benefits: Decouples production and consumption, handles variable rates
  • Remember: Always protect shared queue with mutex

Why This Matters

  • Busy-waiting (checking a condition in a loop) wastes CPU cycles. Condition variables let threads sleep until they are actually needed.
  • Producer-consumer patterns, task queues, and thread pools all rely on condition variables for efficient coordination.
  • They are the foundation of most multithreaded synchronization patterns beyond simple mutex locking.

Critical Insight

Condition variables are like a waiting room with a receptionist. Instead of constantly asking "Is my appointment ready?" (busy-waiting), you sit down and the receptionist calls you when it is your turn.

wait() is you sitting down (and giving up your lock), notify_one() is the receptionist calling one person, and notify_all() is announcing that everyone can come in. The predicate checks if it is really your turn, handling cases where you were woken up by mistake.

Best Practices

Always use wait() with predicate: The form cv.wait(lock, []{ return condition; }) handles spurious wakeups automatically.

Use unique_lock with condition variables: std::unique_lock is required because wait() needs to unlock/relock.

Minimize lock scope: Unlock before doing expensive work after consuming data from the queue.

Use notify_one when possible: Only use notify_all if multiple threads need to wake up for the same event.

Check condition after waking: Even with predicates, always verify the condition is met before proceeding.

Common Mistakes

Forgetting the predicate: Without a predicate, spurious wakeups will cause bugs. Always use cv.wait(lock, predicate).

Using lock_guard: condition_variable requires unique_lock because wait() needs to unlock the mutex.

Notifying without lock: While legal, it can cause missed notifications. Hold the lock when changing the condition.

Not checking condition: Always verify the condition is actually met after wait() returns, even with predicates.

Debug Challenge

This condition variable wait is missing a critical component. Click the highlighted line to fix it:

1 std::mutex mtx;
2 std::condition_variable cv;
3 bool ready{false};
4
5 void waitForReady() {
6 std::unique_lock<std::mutex> lock{mtx};
7 cv.wait(lock);
8 std::cout << "Ready!\n";
9 }

Quick Quiz

  1. Why use condition variables instead of busy-waiting?
Threads sleep instead of wasting CPU cycles checking repeatedly
They are faster than loops
They use less memory
  1. What is a spurious wakeup?
When too many threads are notified
When wait() returns without notify being called
When notify is called too early
  1. Why must condition_variable use unique_lock?
It is faster than lock_guard
It prevents deadlocks
wait() needs to unlock and relock the mutex

Practice Playground

Time to try out what you just learned! Play with the example code below, experiment by making changes and running the code to deepen your understanding.

Lesson Progress

  • Fix This Code
  • Quick Quiz
  • Practice Playground - run once