Lesson 2
Thread-safe Queue with Condition Variables
Thread-safe Queue with Condition Variables

Welcome back to another exciting lesson on lock-based concurrent data structures! In our previous unit, we explored how to implement a thread-safe stack using locks. As we progress, we're going to elevate our synchronization skills by incorporating condition variables. This lesson will help you build a thread-safe queue, which not only handles multiple access with locks but also communicates effectively between threads when specific conditions are met. It's like giving threads the ability to "wait" for the right moment to act. Let's explore this thrilling aspect of concurrent programming.

What You'll Learn

In this lesson, you'll grasp the concepts of implementing a thread-safe queue using both locks and condition variables. Condition variables are used to block a thread until a particular condition is met, contributing to efficient coordination between producer and consumer threads. Here’s a glimpse into what our thread-safe queue might look like:

C++
1class ThreadsafeQueue { 2private: 3 mutable std::mutex mut; 4 std::queue<int> data_queue; 5 std::condition_variable data_cond; 6 7public: 8 void push(int new_value) { 9 std::lock_guard<std::mutex> lk(mut); 10 data_queue.push(std::move(new_value)); 11 data_cond.notify_one(); 12 } 13 14 void wait_and_pop(int& value) { 15 std::unique_lock<std::mutex> lk(mut); 16 data_cond.wait(lk, [this] { return !data_queue.empty(); }); 17 value = std::move(data_queue.front()); 18 data_queue.pop(); 19 } 20 21 bool try_pop(int& value) { 22 std::lock_guard<std::mutex> lk(mut); 23 if (data_queue.empty()) { 24 return false; 25 } 26 value = std::move(data_queue.front()); 27 data_queue.pop(); 28 return true; 29 } 30 31 bool empty() const { 32 std::lock_guard<std::mutex> lk(mut); 33 return data_queue.empty(); 34 } 35};

Notice how std::condition_variable is used alongside std::mutex to allow threads to wait for a queue item to become available before proceeding. This unlocks more sophisticated synchronization techniques compared to simply using locks.

Let's examine the methods in our ThreadsafeQueue class:

  • push(int new_value): Adds a new item to the queue and notifies a waiting thread if one exists. Notice, that we use std::move to transfer the value to the queue efficiently. The condition variable data_cond is used to signal the waiting thread that new data is available.
  • wait_and_pop(int& value): Waits for the queue to become non-empty and then pops the front element. The data_cond.wait() method blocks the thread until the lambda function [this] { return !data_queue.empty(); } returns true.
  • try_pop(int& value): Attempts to pop an item from the queue if it's not empty. This method doesn't block the calling thread.
  • empty() const: Checks if the queue is empty.

Let's see how these methods work together to create a thread-safe queue that can be safely accessed by multiple threads that are pushing and popping items concurrently:

C++
1int main() { 2 ThreadsafeQueue tsq; 3 4 std::thread producer([&tsq] { 5 for (int i = 0; i < 10; ++i) { 6 tsq.push(i); 7 std::this_thread::sleep_for(std::chrono::milliseconds(100)); 8 } 9 }); 10 11 std::thread consumer([&tsq] { 12 int value; 13 for (int i = 0; i < 10; ++i) { 14 tsq.wait_and_pop(value); 15 std::cout << "Consumer got: " << value << std::endl; 16 } 17 }); 18 19 producer.join(); 20 consumer.join(); 21 22 return 0; 23}

In this example, we have a producer thread that pushes values to the queue and a consumer thread that waits for the queue to become non-empty before popping the front element. The producer thread adds values to the queue every 100 milliseconds, and the consumer thread retrieves them as they become available. This demonstrates how condition variables can be used to synchronize threads effectively.

Why It Matters

Mastering thread-safe queues with condition variables is crucial for scenarios where threads must collaborate closely to complete tasks. For instance, in a producer-consumer setup, producers add items to a queue while consumers retrieve them. Utilizing condition variables ensures that consumers don't waste resources by actively polling the queue but instead wait patiently for new data. This efficiency is essential for robust, high-performance applications.

By learning these techniques, you're equipping yourself with the tools to build reliable software systems that can perform seamlessly under concurrent workloads. This knowledge is a stepping stone to understanding complex multi-threaded systems and tackling real-world programming challenges with confidence. Ready to see this in action? Let's move to the practice section and put your new skills to the test!

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