Skip to main content

Welcome to a Grasp lesson

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

Lesson 70

Implementing Thread-Safe Classes with the Monitor Object Pattern

Recommended resources

Hello! Welcome to the first lesson in our module on concurrency and architectural patterns.

In the previous lesson, we mastered the Command pattern. We learned to encapsulate a request into an object, giving us the power to queue, defer, and even reverse actions. We also saw how std::function provides a modern, lightweight way to implement this, creating a "callable" that can be stored and executed later. This is the perfect foundation for today's topic.

Now, we face the next logical challenge: how do we manage a queue of these commands when multiple threads are involved? If one thread is adding tasks while another is trying to remove and execute them, we'll quickly run into chaos and data corruption.

Today, you will learn to implement the Monitor Object pattern to create a thread-safe class by encapsulating data and synchronization logic. This pattern is the canonical solution to this problem and forms the very heart of the thread pool we will build for your portfolio project.

1. The Problem: Why std::queue is Not Thread-Safe

Before we jump to the solution, it's critical to understand the problem at a fundamental level. Your C and OS background gives you a head start here. You know that operations like adding an element to a data structure are not single, atomic machine instructions. They involve multiple steps: allocating memory, updating pointers, modifying size counters, and so on.

When two threads perform these operations concurrently on the same object without coordination, their actions can interleave in destructive ways, leading to corrupted internal state. This is a data race, and it results in undefined behavior.

To see this in action, let's watch a demonstration of a naive producer-consumer setup that uses a global std::queue and inevitably crashes.

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

This video from DeepDiveDev perfectly illustrates the problem. It starts with a simple program where one thread pushes to a queue and another pops from it, showing exactly why it's unsafe.

Watch from the beginning to 05:07. Pay close attention to the explanation of how push and pop are not atomic and how their interleaved execution corrupts the queue's internal pointers. Also, note the discussion of the 'time-of-check to time-of-use' (TOCTTOU) bug, a classic concurrency flaw.

As you saw, even a seemingly safe check like if (!q.empty()) is not enough. The state of the queue can change between the check and the subsequent access, leading to a crash. We need a way to make a sequence of operations an indivisible, atomic unit.


2. The Solution: The Monitor Object Pattern

The Monitor Object pattern provides an elegant, object-oriented solution. It bundles the data to be protected with the synchronization mechanisms required to protect it, presenting a clean, thread-safe interface to the outside world.

The pattern has four key participants, which are best understood through a formal definition and a diagram.

Monitor Object Intent and Structure

The paper 'Monitor Object' by Douglas C. Schmidt is a foundational text on this pattern. We will use it to understand the formal structure.

Read Section 1 ('Intent'), Section 5 ('Problem'), Section 6 ('Solution'), and Section 7 ('Structure'). Focus on the definitions of the four participants: the Monitor object, Synchronized methods, Monitor lock, and Monitor condition.

Here is a visual representation of those components:

UML Class Diagram of the Monitor Object Pattern
This UML diagram from modernescpp.com illustrates the core idea. A client interacts with the Monitor Object's public `syncMethod`s. Internally, the object uses a `MonitorLock` (a mutex) for exclusive access and a `MonitorCondition` (a condition variable) for cooperative scheduling.

To summarize the components in the context of C++11 and later:

  1. The Object's State: The data we need to protect (e.g., a std::queue<Task>).
  2. Synchronized Methods: The public member functions (push, pop) that provide thread-safe access to the state.
  3. Monitor Lock: A mutex, specifically std::mutex, to enforce mutual exclusion, ensuring only one thread can modify the state at a time.
  4. Monitor Condition(s): One or more condition variables, std::condition_variable, that allow threads to efficiently wait for a specific condition to become true (e.g., "the queue is no longer empty") without burning CPU cycles.

The central principle is encapsulation. The client of a Monitor Object calls methods like push() and pop() and doesn't have to worry about locking or unlocking mutexes. All the complex synchronization logic is hidden inside the object itself.


3. Implementation: Building a Thread-Safe Queue

Let's build a ThreadSafeQueue to solidify these concepts. We'll follow a step-by-step process, addressing each concurrency challenge as it arises.

Step 1: Enforcing Mutual Exclusion

Our first step is to fix the data race. We do this by adding a std::mutex to our class and ensuring every access to the internal queue is protected by it.

The modern C++ way to manage a mutex is not through manual lock() and unlock() calls. This is error-prone, especially in the presence of exceptions. Instead, we use the RAII (Resource Acquisition Is Initialization) idiom with a lock guard.

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

Continuing with the DeepDiveDev video, the next section introduces std::mutex and std::lock_guard to solve the initial crash.

Watch from 05:07 to 10:09. Focus on how std::lock_guard is used. Understand that its constructor locks the mutex and its destructor, called automatically at the end of the scope, unlocks it. This guarantees the lock is released, even if an exception is thrown.

This RAII-based locking is a core tenet of robust C++ concurrency. The formal name for this design, where public methods handle locking while internal implementation details do not, is the Thread-Safe Interface pattern, as described in the Schmidt paper and Rainer Grimm's talk.

Step 2: Eliminating Busy-Waiting with std::condition_variable

We've fixed the crash, but we've introduced a new performance problem: busy-waiting. When a consumer thread finds the queue empty, it will repeatedly lock the mutex, check the size, unlock, and immediately loop again, burning CPU cycles for nothing.

From your OS studies, you know the efficient solution is to put the thread to sleep until there's work to do. This is precisely what std::condition_variable is for.

This next step is the most complex but also the most powerful part of the pattern. It requires replacing std::lock_guard with std::unique_lock and using cv.wait().

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

This is the most critical part of the video. It explains how to move from an inefficient polling model to an efficient event-driven model using a condition variable.

Watch carefully from 10:09 to 18:34. Pay special attention to these key concepts: Why std::unique_lock is necessary: it's more flexible than std::lock_guard and allows the wait function to temporarily unlock the mutex. The atomic three-step process of cv.wait(): release lock, suspend thread, and re-acquire lock upon waking. The concept of spurious wakeups and why cv.wait() must always be used with a predicate (the lambda function) inside a loop (while is implied by the predicate version of wait).

Test your understanding!

A common mistake for beginners is to check the condition with an if statement before waiting, like this:

std::unique_lock<std::mutex> lock(m);
if (queue.empty()) {
    cv.wait(lock);
}
// process item

Based on your understanding of spurious wakeups, what is wrong with this code?

Show answer

The problem is that a thread can wake up from cv.wait() for reasons other than a direct notification (a "spurious wakeup"). If our thread wakes up spuriously, it will re-acquire the lock and proceed past the if block, even though the queue is still empty. It would then attempt to access an element from an empty queue, leading to a crash.

The correct pattern uses the wait overload that takes a predicate, which internally handles this in a loop:

std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [this]{ return !queue.empty(); }); // This internally loops
// process item

This ensures that the thread only proceeds if it has been woken up and the condition !queue.empty() is actually true.


4. The Complete ThreadSafeQueue Monitor Object

Now, let's put it all together into a clean, reusable, and exception-safe template class. This final refactoring transforms our scattered globals into a proper Monitor Object.

#include <queue>
#include <mutex>
#include <condition_variable>

template <typename T>
class ThreadSafeQueue {
public:
    ThreadSafeQueue() = default;
    
    void push(T value) {
        // Acquire lock
        std::lock_guard<std::mutex> lock(m_mutex);
        // Add to queue
        m_queue.push(std::move(value));
        // Notify one waiting thread
        m_cond.notify_one();
    }

    // Pass by reference to avoid issues with exception safety
    void wait_and_pop(T& value) {
        // Acquire lock
        std::unique_lock<std::mutex> lock(m_mutex);
        // Wait until queue is not empty. The lambda is the predicate.
        m_cond.wait(lock, [this]{ return !m_queue.empty(); });
        // Retrieve the value
        value = std::move(m_queue.front());
        m_queue.pop();
    }

private:
    std::queue<T> m_queue;
    mutable std::mutex m_mutex;
    std::condition_variable m_cond;
};

This implementation encapsulates all the logic we've discussed. Notice the wait_and_pop function signature. Why does it take T& value as an out-parameter instead of returning a T? This is a subtle but crucial point about exception safety.

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

The final part of the DeepDiveDev video refactors the code into a template class and explains this critical design choice regarding exception safety.

Watch from 18:34 to 23:45. Focus on the explanation for why the pop method takes a reference parameter. This ensures strong exception safety: if copying or moving the item out of the function throws an exception, the item is not lost because it hasn't been removed from the internal queue yet.

This design consideration is exactly the kind of "under the hood" detail that distinguishes a professional C++ developer. By prioritizing correctness and robustness, you ensure your code doesn't lose data even in edge cases.


Conclusion

In this lesson, you have implemented one of the most fundamental patterns in concurrent programming. You've gone from understanding the dangers of data races to building a complete, efficient, and exception-safe ThreadSafeQueue from first principles.

Key Takeaways:

  • Encapsulation is Key: The Monitor Object pattern hides complex synchronization logic (std::mutex, std::condition_variable) behind a simple public interface.
  • RAII for Correctness: Lock guards (std::lock_guard, std::unique_lock) are essential for ensuring mutexes are always released, even when exceptions occur.
  • Efficiency via Signaling: std::condition_variable allows threads to sleep efficiently instead of busy-waiting, preventing wasted CPU cycles.
  • Robustness against Pitfalls: You must handle spurious wakeups by using a predicate with cv.wait(), which effectively checks the condition in a loop.
  • Exception Safety Matters: Designing your API carefully (e.g., passing values by reference) prevents data loss if operations like copy or move constructors throw exceptions.

Next Lesson Preview:
You have now built the engine of our thread pool: a thread-safe work queue. The next logical step is to build the chassis around it. In our next lesson, we will dive into the Thread Pool pattern. We will create a group of worker threads that pull tasks from the very ThreadSafeQueue you just designed, creating a complete system for decoupling task submission from task execution.