Lesson 2
Exploring Inter-thread Communication with Condition Variables
Exploring Inter-thread Communication with Condition Variables

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.

What You'll Learn

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(), and notify_all() methods to manage thread execution flow.

Introduction to `std::condition_variable`

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 the std::unique_lock or std::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.

  1. Mutex and Condition Variable Declaration: We begin by declaring a std::mutex (print_mutex) and a std::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.

  2. Shared Data: The boolean flag ready indicates when the condition has been satisfied. Initially set to false, it determines whether a thread can proceed with its task.

  3. Thread Function (print_id): Inside print_id, we acquire a std::unique_lock on the mutex print_mutex, ensuring exclusive access to the critical section. The thread then enters a loop where it calls cv.wait(lck) to block until the ready condition is met. Upon receiving a notification that the condition is satisfied, the thread resumes execution, enabling it to print its identifier.

  4. Notifier Function (set_ready): The set_ready function simulates work by sleeping for 3 seconds. It then locks the mutex with std::lock_guard, sets the ready flag to true, and prints a message indicating that threads are waiting. Finally, cv.notify_all() is called to unblock all waiting threads. Note, that if you use notify_one() instead of notify_all(), only one thread will be unblocked - the thread is chosen non-deterministically.

  5. Main Function: In the main function, we spawn 10 threads, each calling the print_id function. The threads are initially blocked by the condition variable, waiting for the ready flag to be set to true. After 3 seconds, the set_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.

Understanding std::unique_lock

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 use std::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.
Why It Matters

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!

Enjoy this lesson? Now it's time to practice with Cosmo!
Practice is how you turn knowledge into actual skills.