Skip to main content

Welcome to a Grasp lesson

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

Lesson 18

Protecting Shared Data with Mutex and Lock Guard

Recommended resources

Hello! In our last lesson, we explored the dangerous world of data races and race conditions. You saw how seemingly innocent code, like incrementing a counter from multiple threads, can produce incorrect and unpredictable results due to the non-atomic nature of CPU operations. This leads to Undefined Behavior, the most dreaded outcome in C++.

Today, we transition from identifying the problem to implementing the solution. Our goal is to protect shared data using std::mutex and std::lock_guard. These tools are the cornerstones of lock-based concurrency in C++. They will allow us to define "critical sections" in our code—regions where only one thread is allowed to operate at a time—thereby restoring order and predictability to our concurrent programs.

The Concept of Mutual Exclusion

The fundamental idea behind protecting shared data is mutual exclusion. We need a mechanism to ensure that if one thread is modifying a shared resource, all other threads must wait until it's finished. The C++ standard library provides this mechanism through the std::mutex class.

Think of a std::mutex as a key to a room that can only hold one person. A thread wanting to enter the "room" (the critical section of code) must first acquire the key (lock the mutex). If another thread already has the key, it must wait. Once the first thread is done, it releases the key (unlocks the mutex), allowing a waiting thread to take its turn.

Mutex Synchronization between Two Threads and a Shared Resource
This diagram illustrates the mutex mechanism. Thread A acquires a lock and accesses the shared resource. When Thread B attempts to access the same resource, it is blocked because the mutex is locked. Thread B must wait until Thread A unlocks the mutex before it can proceed.

A First Attempt: Manual Locking

Let's fix the data race from our previous examples. The most direct way to use a std::mutex is by calling its lock() and unlock() member functions around the code that accesses the shared data.

The following video demonstrates exactly this, using a shared std::cout resource as the subject of the data race.

C++ Threading #3: Data Race and Mutex

The video 'C++ Threading #3: Data Race and Mutex' by Bo Qian provides a clear demonstration of our first synchronization primitive, the mutex.

Watch from the beginning to 03:50. The video first recaps the data race problem and then introduces std::mutex with explicit lock() and unlock() calls to fix it.

By wrapping the access to the shared resource between mu.lock() and mu.unlock(), we create a critical section and guarantee that only one thread can execute that code at a time. The data race is eliminated.


The Pitfall of Manual Locking and the Power of RAII

While lock() and unlock() work, they are fragile and considered bad practice in modern C++. Consider this scenario:

std::mutex my_mutex;
SharedData my_data;

void process_data() {
    my_mutex.lock();
    // What if this line throws an exception?
    my_data.do_something_that_might_fail(); 
    my_mutex.unlock(); // This line will never be reached!
}

If an exception occurs within the critical section, the unlock() call is skipped. The mutex remains locked forever, and any other thread waiting for it will be blocked indefinitely. This is a form of deadlock.

The C++ solution to this is a powerful and idiomatic pattern called RAII (Resource Acquisition Is Initialization). The principle is to tie the lifetime of a resource (like a mutex lock) to the lifetime of an object. The resource is acquired in the object's constructor and released in its destructor. Since destructors are guaranteed to be called when an object goes out of scope—whether by normal execution or by an exception causing the stack to "unwind"—the resource is always released correctly.


std::lock_guard: RAII in Action

The C++ library provides the perfect RAII wrapper for this job: std::lock_guard. When you create a std::lock_guard object, its constructor locks the provided mutex. When the lock_guard goes out of scope, its destructor automatically unlocks the mutex.

Our previous example becomes:

#include <mutex> // Required header

void safe_process_data() {
    // The lock_guard acquires the lock on my_mutex upon construction.
    std::lock_guard<std::mutex> guard(my_mutex); 
    
    my_data.do_something_that_might_fail();

} // `guard` goes out of scope here. Its destructor is called,
  // which unlocks my_mutex, even if an exception was thrown.

This code is clean, simple, and exception-safe. You should always prefer std::lock_guard over manual lock() and unlock() calls.

Let's see this in action.

C++ Threading #3: Data Race and Mutex

Manually calling unlock() is risky. The video from Bo Qian continues by introducing std::lock_guard as the much safer, standard C++ pattern for managing locks.

Now watch from 03:50 to 04:59. This short segment explains the problem with manual unlocks and shows how std::lock_guard solves it using RAII.

To reinforce this critical concept, let's see it applied to the producer-consumer problem, which is highly relevant to the telemetry engine we're building.

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

The video 'C++ Multithreading Explained' from DeepDiveDev also provides an excellent breakdown of std::lock_guard in a producer-consumer context.

Watch from 06:50 to 08:35. Pay attention to the explanation of RAII and why std::lock_guard is superior to manual lock()/unlock() calls, especially regarding exceptions.


Best Practices for Structuring Thread-Safe Code

Now that you have the right tool, it's crucial to use it correctly. Effective lock-based concurrency is about careful software design, not just syntax.

1. Encapsulate Your Data and Mutex

A mutex and the data it protects should live together. The best way to enforce this is to make them both private members of a class. This prevents other parts of the code from accessing the data without going through the public member functions, which are responsible for locking the mutex.

2. Don't Leak Handles to Protected Data

This is a critical rule for systems programming. Once you have locked your data inside a class, never return a raw pointer or reference to it from a member function. Doing so creates a backdoor that allows any other thread to access and modify the data directly, completely bypassing the mutex's protection.

The following segment of the Bo Qian video masterfully illustrates both of these points.

C++ Threading #3: Data Race and Mutex

A common mistake is to simply sprinkle locks around global variables. A much more robust approach is to encapsulate the shared data and its mutex within a class.

Watch from 04:59 to 08:43. This is a crucial section. It demonstrates how to bundle a resource and its mutex in a class. Most importantly, it explains what not to do: never return a raw pointer or reference to the protected data.

Let's look at another code example that drives this point home.

C++ Tutorial: Multi-Threaded Programming - Protecting data with mutexes

This article from Bogotobogo provides a textual example of how a function can accidentally pass out a reference to protected data, completely defeating the purpose of the mutex.

Read the section titled 'Protecting data with mutexes'. Focus on the code example with the DataWrapper class. Analyze how the badFunction is able to get a pointer to the protected data and modify it without holding the lock.

3. Design Thread-Safe Interfaces

Even with proper locking inside each member function, you can still have race conditions if your class interface is not designed for concurrency. This is an interface-level bug, not an implementation-level one.

The classic example is a thread-safe stack with separate top() and pop() methods.

  1. Thread A calls top() and gets the value X. The lock is released.
  2. The OS pauses Thread A.
  3. Thread B calls top() and also gets the value X. The lock is released.
  4. Thread A resumes and calls pop(), removing X.
  5. Thread B resumes and calls pop(), removing the next element, Y.

The result: value X was processed twice, and value Y was lost. The solution is to combine top() and pop() into a single atomic operation.

C++ Threading #3: Data Race and Mutex

Using std::lock_guard correctly prevents data races, but it doesn't automatically prevent all race conditions. The next segment of the video demonstrates this with the classic std::stack example.

Watch from 08:43 to the end (14:13). Pay close attention to how two separate, individually-safe operations (top() and pop()) can combine to create a race condition. This highlights the need to design your interfaces with concurrency in mind.

Test your understanding!

You are building a simple logging system. Multiple threads need to write messages to a shared log. Here is the initial, thread-unsafe implementation:

#include <iostream>
#include <vector>
#include <string>
#include <thread>

class Logger {
private:
    std::vector<std::string> messages;

public:
    void add_message(const std::string& msg) {
        messages.push_back(msg);
    }

    void print_last_message() {
        if (!messages.empty()) {
            std::cout << messages.back() << std::endl;
        }
    }
};

void writer_thread(Logger& logger, int id) {
    for (int i = 0; i < 10; ++i) {
        std::string msg = "Message from writer " + std::to_string(id) + ", number " + std::to_string(i);
        logger.add_message(msg);
    }
}

// In main():
// Logger shared_logger;
// std::thread t1(writer_thread, std::ref(shared_logger), 1);
// std::thread t2(writer_thread, std::ref(shared_logger), 2);
// t1.join();
// t2.join();
  1. Identify the data race in the Logger class.
  2. Modify the Logger class to make it thread-safe using std::mutex and std::lock_guard.
  3. Is the print_last_message function, as written, still susceptible to a race condition even after you've added locks inside both methods? Why or why not?
Show answer
  1. The data race occurs in add_message. std::vector::push_back is not thread-safe. When multiple threads call it concurrently, they will try to modify the vector's internal state (size, capacity, data pointers) simultaneously, leading to memory corruption and undefined behavior.

  2. To make it thread-safe, we need to add a std::mutex as a private member and use std::lock_guard in every member function that accesses the shared messages vector.

    #include <iostream>
    #include <vector>
    #include <string>
    #include <thread>
    #include <mutex>
    
    class Logger {
    private:
        std::vector<std::string> messages;
        std::mutex mtx; // The mutex that protects 'messages'
    
    public:
        void add_message(const std::string& msg) {
            std::lock_guard<std::mutex> guard(mtx);
            messages.push_back(msg);
        }
    
        void print_last_message() {
            std::lock_guard<std::mutex> guard(mtx);
            if (!messages.empty()) {
                std::cout << messages.back() << std::endl;
            }
        }
    };
    
  3. No, the print_last_message function itself is not susceptible to a race condition internally anymore, because the check for empty() and the access with back() are both protected by the same lock. This makes the check-and-access sequence atomic. Without the lock, another thread could have cleared the vector between the !messages.empty() check and the messages.back() call, causing a crash. The lock prevents this. This demonstrates a correct application of locking to make a sequence of operations atomic.


Conclusion

Today you've acquired the fundamental tools for writing correct lock-based concurrent programs. You have learned how to eliminate data races and how to start thinking about thread-safe design.

Key Takeaways:

  • std::mutex provides a mechanism for mutual exclusion, allowing only one thread at a time into a critical section.
  • Manual lock() and unlock() calls are dangerous. Always prefer the RAII pattern with std::lock_guard, which guarantees the mutex is unlocked, even in the presence of exceptions.
  • Good concurrent design involves encapsulating the shared data and its mutex within a class.
  • Never leak pointers or references to protected data, as this breaks the encapsulation and bypasses the lock.
  • Simply locking individual methods is not enough; you must design your class interfaces to be atomic and prevent high-level race conditions.

You now have a solid foundation for protecting shared state. However, mutexes come with their own costs. What happens when a consumer thread needs to wait for data from a producer? It could repeatedly lock the mutex, check if the queue is empty, and unlock if it is. This "busy-waiting" wastes CPU cycles.

In our next lesson, we will introduce std::condition_variable, a more advanced primitive that allows threads to wait efficiently for a specific condition to become true without burning CPU time. This will be the next key component in our telemetry engine's data pipeline.