Skip to Content
šŸ‘† We offer 1-on-1 classes as well check now

Condition Variables

Condition variables are a powerful synchronization primitive in C++ that allows threads to efficiently wait for a specific condition to become true. They are used in conjunction with a mutex to prevent race conditions and ensure proper thread synchronization. Unlike busy-waiting (repeatedly checking a condition), condition variables allow threads to sleep until notified, significantly reducing CPU usage.

What are Condition Variables

Condition variables provide a mechanism for threads to suspend execution (wait) until a specific condition is met. They always work in conjunction with a mutex. The mutex protects the shared state that determines the condition. A thread that wishes to wait for a condition:

  1. Acquires the mutex.
  2. Checks if the condition is already true. If so, it proceeds.
  3. If the condition is false, it calls wait() on the condition variable, which atomically releases the mutex and suspends the thread.
  4. When another thread signals the condition variable (using notify_one() or notify_all()), one or more waiting threads are awakened.
  5. The awakened thread reacquires the mutex and rechecks the condition. It’s crucial to recheck the condition because spurious wakeups can occur (the thread might be awakened even if the condition is still false).

Edge Cases and Considerations:

  • Spurious Wakeups: Condition variables can experience spurious wakeups, meaning a thread can be awakened even if no notification was sent or the condition is still false. Therefore, always check the condition within a while loop after a wait() call.
  • Deadlocks: Improper use of condition variables and mutexes can lead to deadlocks. Ensure that all threads release the mutex before waiting and that the signaling thread holds the mutex while modifying the shared state.
  • Starvation: With notify_one(), a specific thread might be repeatedly passed over while others are notified. If fairness is critical, consider using notify_all().
  • Performance: While condition variables are generally more efficient than busy-waiting, excessive signaling or waiting can still impact performance. Design your synchronization logic carefully to minimize unnecessary operations.
  • Exception Safety: Ensure your code is exception-safe. If an exception is thrown between acquiring the mutex and calling wait(), the mutex might not be released, leading to a deadlock. RAII (Resource Acquisition Is Initialization) techniques, like using std::lock_guard or std::unique_lock, can help prevent this.

Syntax and Usage

The std::condition_variable class is defined in the <condition_variable> header.

Key methods:

  • wait(std::unique_lock<std::mutex>& lock): Atomically releases the mutex owned by lock and suspends the thread until notified. When the thread is awakened, it reacquires the mutex.
  • wait(std::unique_lock<std::mutex>& lock, Predicate pred): Same as above, but only resumes execution if pred() returns true. This is equivalent to: while (!pred()) wait(lock); but avoids spurious wakeups being missed.
  • wait_for(std::unique_lock<std::mutex>& lock, const std::chrono::duration<Rep, Period>& rel_time): Waits until notified or until the specified timeout duration has elapsed. Returns std::cv_status::timeout if the timeout occurred, otherwise std::cv_status::no_timeout.
  • wait_until(std::unique_lock<std::mutex>& lock, const std::chrono::time_point<Clock, Duration>& abs_time): Waits until notified or until the specified absolute time point is reached. Returns std::cv_status::timeout if the timeout occurred, otherwise std::cv_status::no_timeout.
  • notify_one(): Wakes up one thread that is waiting on the condition variable.
  • notify_all(): Wakes up all threads that are waiting on the condition variable.

Important: The wait functions require a std::unique_lock because they need to atomically release and reacquire the mutex. A std::lock_guard cannot be used as it doesn’t provide the necessary release/reacquire functionality.

Basic Example

This example demonstrates a simple producer-consumer scenario using a condition variable.

#include <iostream> #include <thread> #include <mutex> #include <condition_variable> #include <queue> std::mutex mtx; std::condition_variable cv; std::queue<int> data_queue; bool producer_finished = false; void producer() { for (int i = 0; i < 10; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); // Simulate data production std::lock_guard<std::mutex> lock(mtx); data_queue.push(i); std::cout << "Producer: Produced " << i << std::endl; cv.notify_one(); // Notify one waiting consumer } { std::lock_guard<std::mutex> lock(mtx); producer_finished = true; } cv.notify_all(); // Notify all consumers that the producer is finished } void consumer() { std::unique_lock<std::mutex> lock(mtx); while (true) { cv.wait(lock, []{ return !data_queue.empty() || producer_finished; }); // Wait until data is available or producer is finished if (data_queue.empty() && producer_finished) { std::cout << "Consumer: Finished consuming." << std::endl; break; } int data = data_queue.front(); data_queue.pop(); std::cout << "Consumer: Consumed " << data << std::endl; } } int main() { std::thread producer_thread(producer); std::thread consumer_thread(consumer); producer_thread.join(); consumer_thread.join(); return 0; }

Explanation:

  1. mtx: A mutex to protect the shared data_queue and producer_finished flag.
  2. cv: The condition variable used for signaling between the producer and consumer.
  3. data_queue: A queue to hold the data produced by the producer and consumed by the consumer.
  4. producer_finished: A boolean flag indicating whether the producer has finished producing data.
  5. producer(): The producer thread generates data, pushes it onto the data_queue, and notifies the consumer via cv.notify_one(). After producing all data, it sets producer_finished to true and uses cv.notify_all() to wake up all consumers, signalling the end of production.
  6. consumer(): The consumer thread waits on the condition variable using cv.wait(). The lambda expression []{ return !data_queue.empty() || producer_finished; } acts as a predicate. The consumer waits until either the data_queue is not empty (meaning there’s data to consume) or the producer_finished flag is true. This handles the case where the producer finishes before the consumer consumes all the data. The while(true) loop and the check if (data_queue.empty() && producer_finished) ensures that the consumer exits gracefully after consuming all data and the producer is finished.

Advanced Example

This example demonstrates a more complex scenario with multiple consumers and a fixed-size buffer.

#include <iostream> #include <thread> #include <mutex> #include <condition_variable> #include <vector> const int BUFFER_SIZE = 5; std::mutex mtx; std::condition_variable cv_empty; std::condition_variable cv_full; std::vector<int> buffer(BUFFER_SIZE); int head = 0; int tail = 0; int count = 0; void producer(int id) { for (int i = 0; i < 20; ++i) { std::unique_lock<std::mutex> lock(mtx); cv_empty.wait(lock, []{ return count < BUFFER_SIZE; }); // Wait until buffer is not full buffer[head] = i; head = (head + 1) % BUFFER_SIZE; count++; std::cout << "Producer " << id << ": Produced " << i << ", count=" << count << std::endl; cv_full.notify_all(); // Notify all consumers that the buffer is not empty lock.unlock(); //Explicit unlock to allow consumers to run sooner std::this_thread::sleep_for(std::chrono::milliseconds(50)); } } void consumer(int id) { for (int i = 0; i < 30; ++i) { // Consume more than produced to show waiting std::unique_lock<std::mutex> lock(mtx); cv_full.wait(lock, []{ return count > 0; }); // Wait until buffer is not empty int data = buffer[tail]; tail = (tail + 1) % BUFFER_SIZE; count--; std::cout << "Consumer " << id << ": Consumed " << data << ", count=" << count << std::endl; cv_empty.notify_all(); // Notify all producers that the buffer is not full lock.unlock(); //Explicit unlock to allow producers to run sooner std::this_thread::sleep_for(std::chrono::milliseconds(75)); } } int main() { std::thread producer1(producer, 1); std::thread producer2(producer, 2); std::thread consumer1(consumer, 1); std::thread consumer2(consumer, 2); std::thread consumer3(consumer, 3); producer1.join(); producer2.join(); consumer1.join(); consumer2.join(); consumer3.join(); return 0; }

Common Use Cases

  • Producer-Consumer Problem: Coordinating data production and consumption between multiple threads.
  • Thread Pools: Managing a pool of worker threads that wait for tasks to be added to a queue.
  • Asynchronous Operations: Waiting for the completion of an asynchronous operation triggered by another thread.

Best Practices

  • Always use a while loop to check the condition after wait(): To handle spurious wakeups.
  • Hold the mutex while modifying the shared state: To prevent race conditions.
  • Use notify_one() if only one waiting thread needs to be awakened: It can be more efficient than notify_all().
  • Use notify_all() when multiple threads might be waiting for the same condition: Or when it’s difficult to determine which thread to notify.
  • Consider timeout mechanisms: To prevent threads from waiting indefinitely if the condition never becomes true.
  • Use RAII (Resource Acquisition Is Initialization) with std::unique_lock: To ensure the mutex is properly released even in the presence of exceptions.

Common Pitfalls

  • Forgetting to recheck the condition after wait(): Leads to incorrect behavior due to spurious wakeups.
  • Not holding the mutex while modifying the shared state: Causes race conditions.
  • Deadlocks: Occur when threads are waiting for each other indefinitely.
  • Starvation: Occurs when a thread is repeatedly passed over for notification.
  • Using std::lock_guard instead of std::unique_lock: std::lock_guard does not allow unlocking/locking, which is required for wait operations.

Key Takeaways

  • Condition variables are a powerful synchronization primitive for thread communication.
  • They allow threads to efficiently wait for specific conditions.
  • Always use them in conjunction with a mutex to protect shared state.
  • Properly handle spurious wakeups and potential deadlocks.
  • Use std::unique_lock with condition variables.
Last updated on