std::lock_guard vs std::unique_lock vs std::scoped_lock
If you’ve ever written concurrent code in C++ or any other language, then you know how difficult it can be to properly protect access to resources without causing data races or deadlocks. C++11 and C++17 have introduced several great locking mechanisms to help programmers write concurrent code, but which one(s) should you use in your own code?
Back to basics: What is a lock?
A lock is a synchronization mechanism that enforces mutual exclusion of threads. In other words, a lock ensures that only one thread can access a shared resource at a time.
To access a shared resource protected by a lock, a thread must acquire (take ownership of) the lock. If the lock is held by another thread, then the new thread will be blocked (put in a waiting state). Once the other thread releases the lock, then the new thread can access the resource by acquiring the lock for itself.
Why is this needed? Imagine if Thread A is reading data from MyData. Now imagine that at the same exact time, Thread B makes a change to MyData. What would you expect Thread A to read? Would it read the original data from MyData before Thread B’s changes, or after? The code where the read happens is called the critical section, and it must be protected through mutual exclusion.
Mutual exclusion gets its name because there is a mutual agreement between threads to take turns owning the lock and accessing the shared resource, such that when one thread holds the lock, other threads are excluded from accessing the shared resource.
Below is an example of code that must use a mutex, but doesn’t…
int sharedCounter = 0;
void incrementCounter() {
for (auto i = 0; i < 1000000; i++) {
sharedCounter++;
}
}
int main() {
std::thread thread1(incrementCounter);
std::thread thread2(incrementCounter);
thread1.join();
thread2.join();
std::cout << "Shared counter: " << sharedCounter << std::endl;
}
What would the output be? You would think that the output would be “Shared counter: 2000000”, but in reality, that number will most likely be something smaller, and differ between runs. Why? It is because both threads are accessing and modifying the shared counter at the same time without mutual exclusion, leading to a data race and unpredictable behavior.
What is std::mutex?
In C++11 and beyond, std::mutex
is a synchronization primitive that is used to protect shared data from simultaneous access by multiple threads. It is typically not used on its own, but rather alongside a lock, like std::unique_lock
or std::lock_guard
.
std::lock_guard vs std::unique_lock
std::lock_guard
is a simple RAII-wrapper for std::mutex
. This means that when you lock a mutex with std::lock_guard
, the ownership will be released automatically when the lock is out of scope. Below is a modified version of the previous example, but with std::lock_guard
and std::mutex
:
int sharedCounter = 0;
std::mutex mutex;
void incrementCounter() {
std::lock_guard<std::mutex> lock(mutex);
for (auto i = 0; i < 1000000; i++) {
counter++;
}
}
int main() {
std::thread thread1(incrementCounter);
std::thread thread2(incrementCounter);
thread1.join();
thread2.join();
std::cout << "Shared counter: " << sharedCounter << std::endl;
}
In this version, the output will always be “Shared counter: 2000000” because the shared counter was protected by the std::mutex
. Furthermore, instead of manually unlocking the lock at the end of incrementCounter
, this is done automatically due to the RAII-style design of std::lock_guard
. In fact, you cannot manually lock/unlock the mutex, so only use std::lock_guard
if you need a very lightweight RAII-wrapper around basic std::mutex
functionality.
A more flexible and robust option is std::unique_lock
. This still follows the RAII paradigm, but also allows you to manually lock and unlock if you desire. Additionally, std::unique_lock
provides functionality for more advanced locking schemes, like deferred locking and try-locking. Also important is the ability to use std::unique_lock
with conditional variables (std::condition_variable
) and the ability to transfer ownership between std::unique_lock
objects.
What about std::scoped_lock?
In C++17, std::scoped_lock
was introduced as a way to prevent deadlocks by locking multiple mutexes. It is most similar to std::lock_guard
, as it is a lightweight RAII-style wrapper for std::mutex
, but it can be used with zero or more mutexes, wherease std::lock_guard
is only for one mutex.
Conclusion
Although concurrency is a tricky subject and can be very confusing, especially with all of the various locking mechanisms in C++, here is my general guidance for choosing a lock for your situation:
- You need to lock exactly 1 mutex: Use
std::lock_guard
withstd::mutex
for its simplicity and RAII-style design. - You need to lock 2 or more mutexes (or 0 in some cases): Use
std::scoped_lock
withstd::mutex
in order to prevent deadlocks. - You need to manually lock/unlock a mutex in a single-scope (including use with a
std::condition_variable
): Usestd::unique_lock
withstd::mutex
since this is the only mutex wrapper that lets you manually lock/unlock.
Please note that these are the more common locking mechanisms that you will likely run into and need to use, but there are more that exist such as std::shared_lock
, and other mutexes such as std::shared_mutex
and std::recursive_mutex
.