As we know already, The std::mutex provides the low level operation of mutual exclusion, but explicitly controlling its lock and unlock is not robust. If an exception is thrown between a successful lock and the matching unlock, or an early return is executed, the mutex will remain locked indefinitely.
Then any other thread that attempts to acquire it will block forever, and a deadlock would be detected at runtime.
To deal with this problem, the standard library provides three RAII wrappers that tie the ownership of a mutex to the lifetime of an automatic object. When the wrapper is constructed, it acquires the mutex (or adopts ownership of an already acquired mutex); when the wrapper is destroyed, it automatically releases the mutex.
Stack unwinding invoked by an exception ensures the destructor is always executed and hence the mutex is unlocked in all exit paths. All three wrappers provide the automatic locking guarantee; they differ in the flexibility they offer and in the number of mutexes they can lock.
std::lock_guard is the simplest RAII guard. It locks the provided mutex in the constructor and unlocks it in the destructor.
The guard is neither movable nor copyable; its scope is strictly tied to the scope where it was declared. There is no mechanism for unlocking it early, deferring the acquisition, or moving ownership; this lack of flexibility is precisely the strength of std::lock_guard: it rules out an entire class of errors that often plague mutex management.
#include <iostream>
#include <thread>
#include <mutex>
int counter = 0;
std::mutex counter_mutex;
void increment()
{
for (int i = 0; i < 100000; ++i) {
// Declare lock before code needs to call it
std::lock_guard<std::mutex> lock(counter_mutex);
++counter;
}
}
int main()
{
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Final value: " << counter << '\n';
return 0;
}
Each iteration of the loop declares a std::lock_guard which immediately locks the counter_mutex. While the guard owns the mutex, the shared counter is incremented.
When the closing brace of the block is reached, the guard's destructor is invoked and it releases the mutex.
If an exception had occurred within the block, the stack unwinding mechanism would have still called the guard's destructor and the mutex would have been released.
Since the critical section is very brief and unconditional, std::lock_guard is exactly what is needed here.
std::unique_lock too guarantees that the mutex will be unlocked in the end. However, it also exposes a richer interface.
The lock may be delayed, it can be acquired on a non blocking call, adopted from an already locked mutex, unlocked early, or even moved to another std::unique_lock object.
This flexibility is essential for certain algorithms; for example, condition variables rely on these advanced features.
But such flexibility also requires from the programmer that one keeps track of more states of the mutex lock object.
The constructor of std::unique_lock accepts an optional locking strategy tag:
std::defer_lockconstructs the guard without actually locking the mutex; it will remain unlocked until explicitly locked later on using lock or try_lock.std::try_to_lockattempts to lock the mutex but doesn't block; the program has to test whether the lock was actually obtained.std::adopt_lockconstructs the guard assuming that the mutex has already been locked by the current thread; the guard will only be responsible for unlocking it in the end.
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
std::mutex data_mutex;
int shared_data = 0;
bool try_update()
{
std::unique_lock<std::mutex> lock(data_mutex, std::try_to_lock);
if (!lock.owns_lock())
return false;
shared_data += 10;
return true;
}
void deferred_work()
{
std::unique_lock<std::mutex> lock(data_mutex, std::defer_lock);
// ... prepare data without holding the mutex
lock.lock();
shared_data += 1;
}
int main()
{
std::thread t1(deferred_work);
std::thread t2([] {
if (try_update())
std::cout << "Update succeeded\n";
else
std::cout << "Update skipped\n";
});
t1.join();
t2.join();
return 0;
}
In try_update, the guard is constructed using std::try_to_lock. The constructor will try to lock the data_mutex non blockingly.
The guard will then be examined to see whether it owns the lock using owns_lock. If another thread has already locked the mutex, the guard will yield without having locked it, so that the shared data can remain unchanged.
In deferred_work, the guard is constructed with std::defer_lock, so the data_mutex will remain unlocked while all the preparation is being carried out. Once the critical section's data is ready, the data_mutex will be locked explicitly using the lock call.
The critical section then only consists of modifying the protected data, which is the shortest possible. Exiting the function by any means causes the destructor of the unique_lock to release the lock, thus preserving the exception safety guarantee.
Since std::unique_lock is movable, locked mutexes can be passed across function boundaries. Moved std::unique_lock will release responsibility from the source object to the destination.
This feature might be useful in some very rare cases where a lock must persist longer than the scope in which it was originally declared, but the lifetime of the lock becomes much more difficult to track down.
If a certain operation has to be performed on two or more unrelated shared data structures, each of which has its own std::mutex that must be locked prior to modification, then the relative order in which the mutexes are locked by different threads can be problematic. If one thread locks the mutex for A and then for B, while another thread locks that for B and then for A, a deadlock will occur.
std::scoped_lock (which was introduced in C++17) solves the problem by accepting any number of mutexes and locking them in an internal algorithm designed to preclude deadlock cycles, and then releasing them all in its destructor. The following example:
#include <iostream>
#include <thread>
#include <mutex>
#include <string>
std::mutex mutex_a;
std::mutex mutex_b;
std::string data_a = "A";
std::string data_b = "B";
void swap_data()
{
// Lock the required mutexes for this scope
std::scoped_lock lock(mutex_a, mutex_b);
std::swap(data_a, data_b);
}
int main()
{
std::thread t1(swap_data);
std::thread t2(swap_data);
t1.join();
t2.join();
std::cout << data_a << ' ' << data_b << '\n';
return 0;
}
will lock both mutexes, and the internal algorithm will ensure that this deadlock order is not formed regardless of other threads involved in locking either mutex by an instance of std::scoped_lock or a well structured series of individual lock calls. The critical modification that swaps the two strings is carried out protected by both mutexes simultaneously.
Upon exiting the scope, the std::scoped_lock releases both mutexes.
If a single mutex is passed to the constructor of std::scoped_lock, it effectively behaves like std::lock_guard, though its real strength comes when locking two or more mutexes concurrently.
Use
std::lock_guardif you need to lock a single mutex immediately and keep it locked until the end of the current scope; this is the simplest and least error prone type of lock acquisition for normal critical sections.Use
std::scoped_lockif you need to lock two or more mutexes together; this prevents common deadlocks.Only use
std::unique_lockwhen you genuinely need some of the advanced features such as deferred locking, attempt without blocking, transferring ownership, or waiting for a condition variable; all this flexibility comes at the cost of added state to manage.
For all three kinds of lock wrappers, the std::mutex itself is the lock object, while the wrapper simply defines when the lock is acquired and released. Minimal localized lock scope and simple wrappers is key for robust concurrent programming.
Subsequent articles will address condition variables, and the way std::unique_lock is used to atomically lock and unlock a mutex while waiting; other articles will focus on certain useful programming patterns of cooperation between multiple concurrent threads.
0 comments
No comments yet. Be the first.