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.

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:
- Consumers must wait if the buffer is empty.
- 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:
- Acquire a
std::unique_lockon the mutex. - Wait on the
not_fullcondition variable untilbuffer_sizeis less thanBUFFER_CAPACITY. The predicate handles this check. - Add the item to the buffer and update the write index (
right) andbuffer_size. - Notify one of the waiting consumer threads via the
not_emptycondition variable, since the buffer is no longer empty. - The lock is released when the
unique_lockgoes 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:
- Acquire a
std::unique_lockon the mutex. - Wait on the
not_emptycondition variable untilbuffer_sizeis greater than0. - Remove the item from the buffer and update the read index (
left) andbuffer_size. - Notify one of the waiting producer threads via the
not_fullcondition variable, as there is now free space. - 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
waitonnot_fullandnotifyonnot_empty; consumerswaitonnot_emptyandnotifyonnot_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.