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:
- Acquires the mutex.
- Checks if the condition is already true. If so, it proceeds.
- If the condition is false, it calls
wait()on the condition variable, which atomically releases the mutex and suspends the thread. - When another thread signals the condition variable (using
notify_one()ornotify_all()), one or more waiting threads are awakened. - 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
whileloop after await()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 usingnotify_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 usingstd::lock_guardorstd::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 bylockand 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 ifpred()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. Returnsstd::cv_status::timeoutif the timeout occurred, otherwisestd::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. Returnsstd::cv_status::timeoutif the timeout occurred, otherwisestd::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:
mtx: A mutex to protect the shareddata_queueandproducer_finishedflag.cv: The condition variable used for signaling between the producer and consumer.data_queue: A queue to hold the data produced by the producer and consumed by the consumer.producer_finished: A boolean flag indicating whether the producer has finished producing data.producer(): The producer thread generates data, pushes it onto thedata_queue, and notifies the consumer viacv.notify_one(). After producing all data, it setsproducer_finishedtotrueand usescv.notify_all()to wake up all consumers, signalling the end of production.consumer(): The consumer thread waits on the condition variable usingcv.wait(). The lambda expression[]{ return !data_queue.empty() || producer_finished; }acts as a predicate. The consumer waits until either thedata_queueis not empty (meaning thereās data to consume) or theproducer_finishedflag is true. This handles the case where the producer finishes before the consumer consumes all the data. Thewhile(true)loop and the checkif (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
whileloop to check the condition afterwait(): 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 thannotify_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_guardinstead ofstd::unique_lock:std::lock_guarddoes 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_lockwith condition variables.