Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 20

Producer-Consumer with Mutexes and Condition Variables

Recommended resources

Hello! Welcome to our next lesson in the "Concurrency Fundamentals with Locks" module.

In our previous session, you learned how to use std::condition_variable to solve the problem of busy-waiting, enabling a thread to sleep efficiently until it's notified that work is available. We covered the mechanics of wait, notify_one, std::unique_lock, and the critical role of predicates in handling edge cases like spurious wakeups.

Today, we'll put all these pieces together. Our learning outcome is to implement a basic producer-consumer pattern using mutexes and condition variables. This is one of the most common and important concurrency patterns. You'll write a complete program where "producer" threads generate data and "consumer" threads process it, all coordinated safely and efficiently through a shared buffer.

This pattern is the heart of the data pipeline for your telemetry engine. Instrumented application threads will act as producers, generating event data, while a dedicated background thread will act as the consumer, consolidating and processing that data. Mastering this pattern is a major step toward building the spine of your project.

The Producer-Consumer Pattern: A High-Level View

The pattern involves two main roles operating on a shared data buffer (like a queue):

  • Producer: Creates data and adds it to the buffer.
  • Consumer: Removes data from the buffer and processes it.

The main challenge is coordinating their access to the shared buffer to prevent errors and inefficiency.

Producer-Consumer Pattern with Condition Variable Analogy
This image provides a simple analogy. The chef (producer) makes food and notifies the child (consumer) when it's ready. The child eats the food and can notify the chef when they are ready for more. The condition variable is the communication channel (the megaphone) that prevents the child from constantly asking "Is it ready yet?" and the chef from constantly checking if the plate is empty.

To begin, let's watch a quick demonstration of the problems we need to solve: data races and undefined behavior that arise from unsynchronized access.

C++ Multithreading Explained: Mutexes, Locks, and Queues

The video 'C++ Multithreading Explained' starts by showing what happens when a producer and consumer access a standard queue without any protection. This is a great refresher on why we need synchronization primitives in the first place.

Watch from 00:42 to 05:07. Focus on understanding the two types of race conditions described: the internal data corruption within the queue's methods (push and pop) and the 'time-of-check to time-of-use' (TOCTOU) bug in the consumer's logic.


Building a Bounded, Thread-Safe Buffer

Our goal is to build a buffer that solves these problems. We will implement a bounded buffer, which means it has a fixed capacity. This is common in real-world systems to prevent uncontrolled memory growth if producers are much faster than consumers.

This introduces two conditions we must manage:

  1. Consumers must wait if the buffer is empty.
  2. Producers must wait if the buffer is full.

We can handle this elegantly with two separate condition variables: one for the "not empty" condition and one for the "not full" condition.

The following article provides a well-structured C++ implementation of a bounded buffer that we will use as our guide.

The Producer Consumer Problem in C++

The article 'The Producer Consumer Problem in C++' by Andrew Wei presents a clear, multi-threaded implementation of a bounded buffer.

First, read the 'Bounded Buffer' section to see the header file for the Buffer class. Note the key components: a raw C-style array for the buffer, indices for tracking the read/write positions (left, right), a size counter, and our synchronization primitives: one std::mutex and two std::condition_variables (not_empty and not_full).

The Producer's Logic

A producer thread wants to add an item to the buffer. Here is the sequence of operations:

  1. Acquire a std::unique_lock on the mutex.
  2. Wait on the not_full condition variable until buffer_size is less than BUFFER_CAPACITY. The predicate handles this check.
  3. Add the item to the buffer and update the write index (right) and buffer_size.
  4. Notify one of the waiting consumer threads via the not_empty condition variable, since the buffer is no longer empty.
  5. The lock is released when the unique_lock goes out of scope (or we can unlock it manually).

Now, let's see this in code.

The Producer Consumer Problem in C++

Let's continue with the same article to analyze the produce() function.

Read the section 'Buffer::produce()'. Study the code and connect it to the logical steps described above. Pay close attention to how not_full.wait() is used with a lambda predicate [this]() { return buffer_size != BUFFER_CAPACITY; }.

The Consumer's Logic

The consumer's logic is the mirror image of the producer's:

  1. Acquire a std::unique_lock on the mutex.
  2. Wait on the not_empty condition variable until buffer_size is greater than 0.
  3. Remove the item from the buffer and update the read index (left) and buffer_size.
  4. Notify one of the waiting producer threads via the not_full condition variable, as there is now free space.
  5. Release the lock and return the consumed item.

The Producer Consumer Problem in C++

Now, examine the consume() function in the same article.

Read the section 'Buffer::consume()'. Notice its symmetrical structure compared to produce(). The predicate for not_empty.wait() is [this]() { return buffer_size != 0; }.

Test your understanding!

In the provided Buffer::produce example, the author calls unique_lock.unlock() manually before calling not_empty.notify_one(). Why might this be a good idea from a performance perspective, even though the lock would be released automatically at the end of the function anyway?

Show answer

Unlocking the mutex before notifying the waiting thread can improve performance by reducing contention. When notify_one() is called, a waiting consumer thread is woken up. That consumer thread will immediately try to acquire the mutex lock.

If the producer is still holding the lock when it calls notify_one(), the newly awakened consumer will be immediately blocked again, waiting for the producer to finish its function and release the lock.

By unlocking first, the producer releases its claim on the shared resource. Then, when it wakes the consumer, the consumer can acquire the lock without having to wait, allowing the two threads to proceed in parallel more quickly.


Launching the Threads

With the Buffer class defined, the final step is to create an instance of it and launch several producer and consumer threads to interact with it.

The Producer Consumer Problem in C++

The article concludes by showing the main function and the simple functions that the threads will execute.

Read the final section, 'Producer Consumer Code'. Observe how threads are created with std::thread and how the shared buffer object is passed to them using std::ref. Also, note the use of join() to ensure the main thread waits for all other threads to complete.

You now have a complete, working example of the producer-consumer pattern.


Refactoring into a Reusable ThreadSafeQueue

The solution we just reviewed works, but it's not ideal C++ design. The concurrency logic (mutexes, condition variables) is mixed with the application logic (the main function). A much better approach is to encapsulate all the synchronization primitives inside a class, providing a clean, easy-to-use public interface.

This is a key principle of modern C++: build components that manage their own resources and complexity. Let's create a generic, reusable ThreadSafeQueue.

C++ Multithreading Explained: Mutexes, Locks, and Queues

The final part of the 'C++ Multithreading Explained' video does exactly this. It refactors the producer-consumer logic into an excellent, reusable, and exception-safe template class.

Watch from 18:34 to 23:01. This is a very dense and valuable segment. Focus on: Encapsulation: How the std::mutex and std::condition_variable are now private members of the class. Templates (template<typename T>): How this makes the queue generic, able to hold int, std::string, or any custom object. Exception Safety in pop: Pay very close attention to the explanation of why the pop method takes an output reference (T& value) instead of returning a T by value. This is a critical C++ design pattern to prevent data loss if a copy constructor throws an exception.

By creating this ThreadSafeQueue class, you've moved from a one-off solution to a professional-grade, reusable component. The complexity is hidden, the interface is clean, and the behavior is safe and efficient. This is exactly the kind of component you will build and rely on in larger systems.


Conclusion

In this lesson, you have synthesized everything you've learned about C++ concurrency so far to implement the classic producer-consumer pattern. This is a significant milestone.

Key Takeaways:

  • The producer-consumer pattern is a fundamental model for passing data between threads.
  • A bounded buffer uses a fixed-size data structure and requires producers to wait when full and consumers to wait when empty.
  • Using two std::condition_variables (not_full, not_empty) can create a clear and robust implementation for a bounded buffer.
  • The logic is symmetrical: producers wait on not_full and notify on not_empty; consumers wait on not_empty and notify on not_full.
  • Encapsulating concurrency logic within a class (like a ThreadSafeQueue) is a superior design that promotes reusability, safety, and cleaner application code.
  • Exception safety is a critical consideration in C++ design, influencing API choices like passing by reference instead of returning by value.

You now have a solid, lock-based solution for a multi-threaded data queue. In the next lesson, we will begin to analyze the performance limitations of this approach. We'll explore lock contention (what happens when many threads try to acquire the same lock) and false sharing (a subtle hardware-level issue). This will motivate our future exploration of lock-free data structures, which are essential for achieving the "zero-overhead" goal of your telemetry profiler.