Welcome to the next chapter in our journey through C++ concurrency. In our previous lesson, we delved into synchronization primitives using std::atomic
and learned how to manage shared data effectively with lock-free programming. Building on that knowledge, this lesson will introduce you to inter-thread communication using condition variables. Condition variables are a crucial part of the concurrency toolkit, allowing threads to coordinate their activities seamlessly.
In this lesson, you'll gain a solid understanding of how to use condition variables for better inter-thread communication:
-
Introduction to
std::condition_variable
: You'll learn about its purpose and how it enables threads to wait for certain conditions or events to occur before proceeding. -
Implementing Wait and Notify Patterns: We'll explore how to use the
wait()
,notify_one()
, andnotify_all()
methods to manage thread execution flow.
A std::condition_variable
is a synchronization primitive that allows threads to wait for a specific condition to be met before proceeding. It is often used in conjunction with a std::mutex
to protect shared data and coordinate the activities of multiple threads. Condition variables provide a mechanism for threads to block efficiently, reducing CPU usage and improving responsiveness.
The key methods associated with std::condition_variable
are:
wait(lock)
: This method blocks the current thread until the condition variable is notified or a spurious wakeup occurs. It releases the lock associated with thestd::unique_lock
orstd::lock_guard
object passed as an argument, allowing other threads to acquire the lock.notify_one()
: This method notifies one waiting thread, if any, that the condition has changed. The notified thread will wake up and attempt to reacquire the lock.notify_all()
: This method notifies all waiting threads that the condition has changed. Each thread will wake up and attempt to reacquire the lock.
Consider the following code snippet, which demonstrates a simple example of using condition variables in action:
C++1#include <iostream> 2#include <thread> 3#include <mutex> 4#include <condition_variable> 5 6std::mutex print_mutex; // Mutex for critical section 7std::condition_variable cv; // Condition variable to block thread 8bool ready = false; // Shared data (condition) 9 10void print_id(int id) { 11 std::unique_lock<std::mutex> lck(print_mutex); // Acquire the lock 12 while (!ready) cv.wait(lck); // Wait until the condition is met. When the 'ready' flag is true, the thread will be unblocked and continue execution 13 // Proceed after the condition is met 14 std::cout << "Thread " << id << '\n'; 15} 16 17void set_ready() { 18 std::this_thread::sleep_for(std::chrono::seconds(3)); // simulate work 19 { 20 std::lock_guard<std::mutex> lck(print_mutex); // Lock the mutex 21 ready = true; // Set the condition to true 22 } 23 std::cout << "Threads are still waiting" << std::endl; 24 cv.notify_all(); // Notify all waiting threads that the condition has changed 25} 26 27int main() { 28 std::thread threads[10]; 29 // Spawn 10 threads 30 for (int i = 0; i < 10; ++i) { 31 threads[i] = std::thread(print_id, i); 32 } 33 std::cout << "Threads are waiting for the condition to be met..." << std::endl; 34 set_ready(); // Set the condition to true after 3 seconds 35 for (auto& th : threads) { 36 th.join(); 37 } 38 return 0; 39}
In this code snippet, we explore the use of condition variables for inter-thread communication through a simple example.
-
Mutex and Condition Variable Declaration: We begin by declaring a
std::mutex
(print_mutex
) and astd::condition_variable
(cv
). The mutex is used to protect access to the shared data (ready
), while the condition variable provides the mechanism for threads to block and be notified when the state changes. -
Shared Data: The boolean flag
ready
indicates when the condition has been satisfied. Initially set tofalse
, it determines whether a thread can proceed with its task. -
Thread Function (
print_id
): Insideprint_id
, we acquire astd::unique_lock
on the mutexprint_mutex
, ensuring exclusive access to the critical section. The thread then enters a loop where it callscv.wait(lck)
to block until theready
condition is met. Upon receiving a notification that the condition is satisfied, the thread resumes execution, enabling it to print its identifier. -
Notifier Function (
set_ready
): Theset_ready
function simulates work by sleeping for 3 seconds. It then locks the mutex withstd::lock_guard
, sets theready
flag totrue
, and prints a message indicating that threads are waiting. Finally,cv.notify_all()
is called to unblock all waiting threads. Note, that if you usenotify_one()
instead ofnotify_all()
, only one thread will be unblocked - the thread is chosen non-deterministically. -
Main Function: In the
main
function, we spawn 10 threads, each calling theprint_id
function. The threads are initially blocked by the condition variable, waiting for theready
flag to be set totrue
. After 3 seconds, theset_ready
function is called, changing the condition and notifying all waiting threads. The threads are then unblocked and proceed to print their identifiers.
This code exemplifies a basic producer-consumer pattern, where set_ready
acts as the producer that meets the condition, allowing the consumer threads in print_id
to proceed with their task after the condition has changed.
In the code snippet above, we used std::unique_lock
to acquire the mutex lock. std::unique_lock
is a more flexible alternative to std::lock_guard
that allows you to unlock the mutex explicitly.
This feature is particularly useful when working with condition variables, as it enables you to release the lock before calling cv.wait()
and reacquire it when the condition is met.
We need to use std::unique_lock
over std::lock_guard
in the following scenarios:
- Unlocking the Mutex Explicitly: If you need to unlock the mutex before calling
cv.wait()
, you should usestd::unique_lock
. This allows you to release the lock and reacquire it when the condition is met. - Conditionally Waiting: If you need to wait for a condition to be met, you can use
std::unique_lock
to unlock the mutex and block the thread until the condition is satisfied. - Manual Lock Management: If you need to manage the lock manually, such as unlocking it in one part of the code and reacquiring it later,
std::unique_lock
provides the flexibility to do so.
Understanding condition variables is key to building programs that involve complex thread interactions. Whether you’re developing software that requires resource sharing, implementing producer-consumer models, or coordinating tasks, condition variables provide the necessary synchronization mechanisms. By mastering this concept, you enhance your ability to control thread behavior, reduce unnecessary CPU usage, and build responsive applications.
Does the prospect of mastering these concepts excite you? Gear up for the practice section, where you'll apply what you've learned and transform this knowledge into practical skills!