Hello! Let's continue our journey into C++ concurrency.
In our last lesson, we confronted the dangers of concurrent programming head-on. We dissected race conditions, saw how simple operations like x++ are non-atomic read-modify-write sequences, and established the most important rule: a data race in C++ results in Undefined Behavior. We concluded that there's no such thing as a "benign" data race and that tools like volatile are not the answer for inter-thread synchronization.
Today, we move from problem to solution. This lesson is about implementing critical sections—the cornerstone of safe concurrent programming. You will learn to use std::mutex to protect shared data and, crucially, how to use RAII wrappers like std::lock_guard and std::unique_lock to do so safely and robustly. This is the standard C++ mechanism for eliminating data races.
The Solution: Mutual Exclusion
To prevent multiple threads from interfering with each other while accessing shared data, we need to enforce mutual exclusion. This means only one thread can be in the "critical section" of code at any given time. The C++ standard library provides the std::mutex class for this purpose.
Think of a std::mutex as a key to a room. Before a thread can enter the room (the critical section), it must acquire the key (lock the mutex). While it holds the key, no other thread can enter. When it leaves the room, it must release the key (unlock the mutex) so another waiting thread can get it.
Let's watch how this solves the producer-consumer data race we analyzed conceptually in the last lesson.
C++ Multithreading Explained: Mutexes, Locks, and Queues
The video 'C++ Multithreading Explained' by DeepDiveDev provides an excellent demonstration of fixing a race condition. The first part recaps the problem, and the second part implements the solution with std::mutex and std::lock_guard.
Watch from the beginning to 10:09. The first five minutes (00:00 - 05:07) quickly review the race condition problem with a producer-consumer queue, which should be very familiar. The core of this segment is from 05:07 to 10:09. Pay close attention to: The introduction of std::mutex. The use of std::lock_guard to create a critical section. The explanation of why std::lock_guard is superior to manual lock() and unlock() calls, especially regarding exceptions (the RAII principle).
RAII Wrappers: std::lock_guard for Safety
As the video highlighted, manually calling mtx.lock() and mtx.unlock() is risky:
- Exceptions: If an exception is thrown inside the critical section, the
unlock()call is skipped, the mutex remains locked forever, and any other thread waiting on it will be stuck in a deadlock. - Complex Logic: If your function has multiple return paths, you must remember to call
unlock()before every singlereturn. This is error-prone and hard to maintain.
C++ provides an elegant solution using the RAII (Resource Acquisition Is Initialization) idiom. std::lock_guard is a simple RAII wrapper for a mutex.
When you create a std::lock_guard object, its constructor automatically calls lock() on the mutex you give it.
#include <mutex>
std::mutex mtx;
void safe_function() {
std::lock_guard<std::mutex> guard(mtx); // Mutex is locked here
// ... critical section: safe to access shared data ...
} // `guard` goes out of scope here. Its destructor is called, which unlocks the mutex.
When the lock_guard object goes out of scope (at the closing brace }), its destructor is automatically called. The destructor calls unlock() on the mutex. This is guaranteed to happen, even if an exception is thrown, because the C++ language ensures that destructors of objects on the stack are called during stack unwinding.
This makes your code exception-safe and much cleaner. You should always prefer RAII wrappers over manual locking.
Under the Hood: How Does a Mutex Work?
You're interested in how things work from first principles, so let's dive into the implementation of a std::mutex. It's not just a simple flag; it's a sophisticated two-phase mechanism designed for performance.
On Linux, std::mutex is typically implemented as a wrapper around the POSIX thread mutex, pthread_mutex_t. This object's implementation is highly optimized.

As the diagram shows, locking involves two main phases:
-
The Fast Path (User-space): When you call
lock(), the mutex first tries to acquire the lock using a fast, atomic compare-and-swap (CAS) instruction in user-space. If the lock is free (uncontended), this single atomic instruction succeeds, the thread acquires the lock, and the function returns immediately. There is no expensive context switch or kernel involvement. This is the common case and is extremely fast. -
The Slow Path (Kernel-space): If the atomic CAS fails, it means another thread already holds the lock (the lock is contended). Now, spinning in a loop waiting for the lock would waste CPU cycles. Instead, the mutex transitions to the slow path. It makes a system call to the kernel—on Linux, this is the
futex(Fast User-space Mutex) syscall. The kernel puts the thread to sleep and places it in a wait queue associated with that specific futex. When the thread that owns the lock callsunlock(), it makes anotherfutexsyscall to wake up one of the sleeping threads from the queue.
This two-phase approach is called an adaptive mutex. It's fast in the uncontended case and CPU-efficient (by sleeping) in the contended case.
HFT 01 - Build your own std::mutex
The article 'Build your own std::mutex' by 'brocbyte' provides a fantastic step-by-step evolution of a mutex implementation. We won't build one from scratch, but understanding the progression from a naive spinlock to a sophisticated futex will solidify your mental model.
Read the following sections to understand the design choices: Attempt #1 - Test-And-Set Spinlock: This is the most basic form. Understand why it's correct but inefficient (busy-waiting) under contention. Attempt #4 - TTAS Spinlock with yield(): A small improvement where the spinning thread yields its CPU timeslice, telling the OS it can schedule something else. This is more CPU-friendly than pure spinning. Attempt #5 - Simple Futex: This is the key transition. Understand how it avoids spinning by using a system call to put the thread to sleep. Attempt #6 - Two-Phase Lock aka Adaptive Futex: This combines the previous ideas. It spins for a short time (hoping the lock is released quickly) before giving up and making the expensive system call to sleep. This is what modern mutexes do.
The More Flexible Lock: std::unique_lock
std::lock_guard is perfect for simple, scoped locking. However, sometimes you need more control. For these cases, C++ provides std::unique_lock.
std::unique_lock is also an RAII wrapper, but it's more powerful and flexible than std::lock_guard. This flexibility comes at the cost of slightly more overhead (it's a larger object and does more bookkeeping).
Here are the key differences and features of std::unique_lock:
| Feature | std::lock_guard<std::mutex> |
std::unique_lock<std::mutex> |
|---|---|---|
| Ownership | Strictly scoped, cannot be moved or transferred. | Owns the lock, can be moved to transfer ownership. |
| Locking Strategy | Locks on construction, unlocks on destruction. | Can be locked/unlocked multiple times. Can be constructed unlocked. |
| Flexibility | Minimal. Simple and efficient. | High. Can be used with condition variables (our next topic!). |
| Manual Control | None. | Can call lock(), unlock(), try_lock() on the unique_lock object. |
When to use std::unique_lock?
-
Deferred Locking: You can create the lock object without locking the mutex immediately.
std::unique_lock<std::mutex> u_lock(mtx, std::defer_lock); // ... do some non-critical work ... u_lock.lock(); // Lock it later when needed. // ... critical section ... -
Transferring Lock Ownership: Because
std::unique_lockis movable, you can transfer ownership of a lock from one scope to another.std::unique_lock<std::mutex> get_lock() { std::unique_lock<std::mutex> u_lock(mtx); // ... prepare some data under lock ... return u_lock; // Ownership is moved out of the function } void use_it() { std::unique_lock<std::mutex> my_lock = get_lock(); // The mutex is still locked here. } -
Temporary Unlocking: You can temporarily release the lock to allow other threads to run, and then re-acquire it. This is useful for long-running operations inside a critical section that don't actually need the lock.
std::unique_lock<std::mutex> u_lock(mtx); // Critical section part 1 u_lock.unlock(); // Temporarily release the lock // ... do a long, non-critical operation ... u_lock.lock(); // Re-acquire the lock // Critical section part 2
The most important use case for std::unique_lock, however, is its use with std::condition_variable, which we will cover in our next lesson.
General Guideline: Start with std::lock_guard. If you find you need the extra flexibility offered by std::unique_lock (like deferred locking or use with condition variables), then switch to it.
Test your understanding!
You are writing a function that processes items from a shared queue. The function needs to:
- Lock a mutex to safely pop an item from the queue.
- Process the item. This processing takes a long time and does not involve any other shared data.
- Lock the same mutex again to update a shared counter of processed items.
Which RAII lock wrapper (std::lock_guard or std::unique_lock) is more suitable for this scenario, and why? Sketch out the code for the function body.
Show answer
std::unique_lock is more suitable for this scenario.
Why: The requirement to release the lock during the long processing step and then re-acquire it cannot be met by std::lock_guard, which holds the lock for its entire scope. std::unique_lock provides the necessary flexibility with its manual unlock() and lock() methods. Holding the lock during the long processing step would be inefficient, as it would block other threads from accessing the queue or counter for no reason.
Code Sketch:
#include <mutex>
#include <queue>
// Assume these are shared globally
std::mutex shared_mutex;
std::queue<int> shared_queue;
int processed_count = 0;
void process_item() {
int item_to_process;
// Use a unique_lock to manage the mutex
std::unique_lock<std::mutex> lock(shared_mutex);
if (!shared_queue.empty()) {
item_to_process = shared_queue.front();
shared_queue.pop();
} else {
// Nothing to do, just return. The lock will be released by the destructor.
return;
}
// --- Critical part over, release the lock ---
lock.unlock();
// --- Long processing step (no lock held) ---
// process(item_to_process);
// --- Re-acquire the lock to update the counter ---
lock.lock();
processed_count++;
} // Destructor of 'lock' is called here, ensuring the mutex is unlocked if still held.
Conclusion
Today we've armed ourselves with the primary tool for preventing data races. You now have a robust understanding of how to protect shared data in a multithreaded C++ program.
Key Takeaways:
- A critical section is a piece of code that accesses shared resources and must not be executed by more than one thread at a time.
std::mutexis the C++ primitive for enforcing mutual exclusion.- Using manual
lock()andunlock()is dangerous and not exception-safe. Always use an RAII wrapper. std::lock_guardis a lightweight, efficient RAII wrapper for simple scoped locking.std::unique_lockis a more flexible (and slightly more expensive) RAII wrapper that allows for deferred locking, manual control, and ownership transfer.- Under the hood, modern mutexes are adaptive, using fast user-space atomic operations for uncontended locks and efficient kernel-level sleeping (
futex) for contended locks.
What's Next?
We've solved the data race problem, but we've created a new one. What if a consumer thread locks a queue, finds it empty, and wants to wait until an item is available? With only a mutex, its only option is to unlock, then immediately try to lock again in a loop, wasting CPU cycles. This is called busy-waiting.
In our next lesson, we will solve this problem by introducing std::condition_variable, a synchronization primitive that allows threads to wait efficiently for a specific condition to become true without burning CPU. We will see that std::condition_variable and std::unique_lock work together as a powerful team.