std::lock, std::trylock in C++
Concurrency and synchronization are essential aspects of modern software development. In multi-threaded environments, ensuring proper synchronization is crucial to prevent race conditions and maintain data integrity. C++11 introduced the std::lock utility as part of the <mutex> library to simplify the process of acquiring multiple locks atomically.
Also C++ provides lock wrapper classes as part of its standard library to simplify lock management and ensure safe and efficient synchronization. These lock wrapper classes adhere to the RAII (Resource Acquisition Is Initialization) principle, making it easier to write correct and exception-safe code.
What is std::lock()?
std::lock() is a variadic template function provided in the <mutex> header that locks multiple mutexes at once using a deadlock-free algorithm.
It takes multiple lockable objects (e.g., mutexes) as arguments and acquires them in a way that prevents deadlocks. If any of the locks cannot be acquired immediately, std::lock will block until it can acquire all the locks atomically.
Syntax:
The syntax of std::lock is as follows:
template <class Lockable1, class Lockable2, class... LockableN>
void lock(Lockable1& lock1, Lockable2& lock2, LockableN&... lockN);
Usage with Mutex:
void std::lock(std::mutex& m1, std::mutex& m2, ...);
Why Use std::lock()?
The Deadlock Problem:
When multiple threads lock different mutexes in different orders, they can get stuck waiting for each other indefinitely, causing a deadlock.
Example of Deadlock (Without std::lock()):
#include <iostream>
#include <mutex>
#include <thread>
std::mutex m1, m2;
void thread1() {
m1.lock();
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // Simulating work
m2.lock(); // ⚠️ Potential deadlock
std::cout << "Thread 1 acquired both locks\n";
m2.unlock();
m1.unlock();
}
void thread2() {
m2.lock();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
m1.lock(); // ⚠️ Potential deadlock
std::cout << "Thread 2 acquired both locks\n";
m1.unlock();
m2.unlock();
}
int main() {
std::thread t1(thread1);
std::thread t2(thread2);
t1.join();
t2.join();
return 0;
}
Here, Thread 1 locks m1 first, while Thread 2 locks m2 first. Both then try to lock the other mutex, leading to a deadlock.
Solution: Using std::lock()
To fix this, replace manual locking with std::lock():
std::mutex m1, m2;
void thread1() {
std::lock(m1, m2); // Locks both mutexes safely
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout << "Thread 1 acquired both locks\n";
m1.unlock();
m2.unlock();
}
void thread2() {
std::lock(m1, m2); // Locks both mutexes safely in the same order
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout << "Thread 2 acquired both locks\n";
m1.unlock();
m2.unlock();
}
/*
Thread 1 acquired both locks
Thread 2 acquired both locks
*/
No deadlocks! std::lock() ensures all mutexes are locked atomically and in a deadlock-free manner.
Note: Manual unlocking the mutex e.g m1.unlock(); is mandatory after the usage of resource i.e aftter the critical section. It will not unlocked automtically after the scope ends i.e not RAII.
std::lock with Iterators:
This API allows you to acquire locks on a range of lockable objects defined by iterators. It acquires the locks in the order specified by the iterator range, preventing deadlocks. If any of the locks cannot be acquired immediately, std::lock will block until it can acquire all the locks atomically.
Example:
std::vector<std::mutex> mutexes(5);
// ...
void doSomething() {
std::lock(mutexes.begin(), mutexes.end()); // Acquire all mutexes in the vector
// Critical section protected by all mutexes
for (auto& mutex : mutexes)
mutex.unlock();
}
std::try_lock() :
This API is similar to std::lock but attempts to acquire the locks without blocking. It returns an integer value indicating the result of the acquisition:
template <class Lockable1, class Lockable2, class... LockableN>
int try_lock(Lockable1& lock1, Lockable2& lock2, LockableN&... lockN);
Example using both lock() and try_lock():
#include<iostream>
#include <thread>
#include <mutex>
#include <vector>
#include <algorithm>
#include <functional>
#include <iterator>
std::mutex mutex1, mutex2, mutex3;
void threadFunction() {
// Acquiring locks using std::lock with multiple arguments
std::lock(mutex1, mutex2, mutex3);
// Perform some critical section operations
std::cout << "Thread ID: " << std::this_thread::get_id() << " acquired all locks" << std::endl;
// Release the locks
mutex1.unlock();
mutex2.unlock();
mutex3.unlock();
}
int main() {
std::vector<std::thread> threads;
// Create multiple threads
for (int i = 0; i < 5; ++i){
threads.emplace_back(threadFunction);
}
// Wait for all threads to finish
for (auto& thread : threads) {
thread.join();
}
// Acquiring locks using std::try_lock
bool lockAcquired = std::try_lock(mutex1, mutex2, mutex3);
if (lockAcquired) {
// Locks acquired
std::cout << "Locks acquired using std::try_lock" << std::endl;
// Release the locks
mutex1.unlock();
mutex2.unlock();
mutex3.unlock();
}
else {
// Failed to acquire all locks
std::cout << "Failed to acquire all locks using std::try_lock" << std::endl;
}
return 0;
}
/*
amit@DESKTOP-9LTOFUP:~/OmPracticeC++/Threads$ g++ LockExample.cpp
amit@DESKTOP-9LTOFUP:~/OmPracticeC++/Threads$ ./a.out
Thread ID: 140276159284800 acquired all locks
Thread ID: 140276150830656 acquired all locks
Thread ID: 140276142376512 acquired all locks
Thread ID: 140276133922368 acquired all locks
Thread ID: 140276125468224 acquired all locks
Locks acquired using std::try_lock
*/
Benefits and Advantages:
Considerations and Limitations:
Recommended by LinkedIn
How std::lock function can prevent deadlock when multiple threads try to acquire the lock simultaneously?
The std::lock function is used to acquire multiple locks atomically, meaning that it either acquires all the locks or none at all. It ensures that multiple locks are acquired in a way that avoids deadlock, even if the locks are acquired in different order by different threads.
Here's how std::lock works to prevent deadlock:
By using std::lock, the potential for deadlock is eliminated because it ensures that multiple locks are acquired in a consistent and deadlock-free order. Even if different threads attempt to acquire locks in different orders, the algorithm used by std::lock resolves any conflicts and avoids deadlock situations.
Keys points :
Using std::lock() with std::lock_guard and std::unique_lock
std::lock_guard and std::unique_lock can be used alongside std::lock() for safer, RAII-style unlocking.
Using std::lock() with std::lock_guard
Example with std::lock_guard:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx1;
std::mutex mtx2;
void print_thread_id(int id) {
// Lock both mutexes in a deadlock-free manner
std::lock(mtx1, mtx2);
// Use std::lock_guard to manage the locked mutexes
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
std::cout << "Thread ID: " << id << std::endl;
// Mutexes are automatically unlocked when 'lock1' and 'lock2' go out of scope
}
int main() {
std::thread t1(print_thread_id, 1);
std::thread t2(print_thread_id, 2);
t1.join();
t2.join();
return 0;
}
/*
Thread ID: 1
Thread ID: 2
*/
In the above example, std::lock(mtx1, mtx2) is used to lock both mtx1 and mtx2 in a deadlock-free manner. The std::lock_guard objects lock1 and lock2 are then created with the std::adopt_lock tag, which indicates that the mutexes are already locked and should be managed by the std::lock_guard objects. This ensures that the mutexes are properly unlocked when the std::lock_guard objects go out of scope.
Using std::lock() with std::unique_lock
std::unique_lock provides more flexibility, such as deferred locking and explicit unlocking.
Example with std::unique_lock:
void print_thread_id(int id) {
// Create unique_lock objects without locking the mutexes
std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
// Lock both mutexes in a deadlock-free manner
std::lock(lock1, lock2);
std::cout << "Thread ID: " << id << std::endl;
// Mutexes are automatically unlocked when 'lock1' and 'lock2' go out of scope
}
/*
Thread ID: 1
Thread ID: 2
*/
In the above example, std::unique_lock objects lock1 and lock2 are created with the std::defer_lock tag, which means the mutexes are not locked immediately. The std::lock(lock1, lock2) function is then used to lock both mutexes in a deadlock-free manner. The std::unique_lock objects ensure that the mutexes are automatically unlocked when they go out of scope, making the code more robust and less error-prone.
Lock wrapper:
C++11 introduced several locking mechanisms to simplify thread synchronization and prevent race conditions. Among them, std::lock_guard, std::unique_lock, and std::scoped_lock provide efficient ways to manage mutexes while minimizing the risk of deadlocks.
BTW what is the difference between std::scoped_lock(C++17) and std::lock(C++11) API
The std::scoped_lock and std::lock API in C++ both serve the purpose of managing multiple mutexes, but they do so in different ways and offer different levels of convenience and safety.
std::scoped_lock
std::scoped_lock is an RAII (Resource Acquisition Is Initialization) wrapper that locks multiple mutexes when it is created and automatically unlocks them when it goes out of scope. It ensures that the mutexes are locked in a deadlock-free manner by using a specific locking order. This makes it a convenient and safe way to manage multiple mutexes without having to worry about manually unlocking them.
example of using std::scoped_lock:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx1;
std::mutex mtx2;
void print_thread_id(int id) {
std::scoped_lock lock(mtx1, mtx2); // Lock both mutexes
std::cout << "Thread ID: " << id << std::endl;
// Mutexes are automatically unlocked when 'lock' goes out of scope
}
int main() {
std::thread t1(print_thread_id, 1);
std::thread t2(print_thread_id, 2);
t1.join();
t2.join();
return 0;
}
In the above example, std::scoped_lock locks both mtx1 and mtx2 when it is created and automatically unlocks them when it goes out of scope, ensuring proper resource management and preventing deadlocks.
std::lock()
As discussed earlier std::lock is a function that locks multiple mutexes in a deadlock-free manner but does not provide automatic unlocking. It is typically used in conjunction with std::unique_lock to manually manage the locking and unlocking of multiple mutexes.
Example of using std::lock()
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx1;
std::mutex mtx2;
void print_thread_id(int id) {
std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
std::lock(lock1, lock2); // Lock both mutexes in a deadlock-free manner
std::cout << "Thread ID: " << id << std::endl;
// Mutexes are automatically unlocked when 'lock1' and 'lock2' go out of scope
}
int main() {
std::thread t1(print_thread_id, 1);
std::thread t2(print_thread_id, 2);
t1.join();
t2.join();
return 0;
}
/*
Thread ID: 1
Thread ID: 2
*/
In this example, std::lock is used to lock both mtx1 and mtx2 in a deadlock-free manner. The std::unique_lock objects lock1 and lock2 are then responsible for unlocking the mutexes when they go out of scope.
Key Differences
In summary, std::scoped_lock offers a simpler and more convenient way to manage multiple mutexes, while std::lock provides greater flexibility and control. Both are useful tools for ensuring safe and efficient mutex management in concurrent programming.
The std::lock() utility is a powerful synchronization tool introduced in modern C++ to simplify the acquisition of multiple locks atomically. It ensures deadlock avoidance, improves code readability, provides exception safety, and allows for potential performance optimizations. By leveraging std::lock, developers can write concurrent code that is more concise, maintainable, and robust. Understanding and utilizing std::lock is essential for effective concurrent programming in C++, enabling efficient synchronization and data protection in multi-threaded environments.
Although I don't write C++ now-a-days, your articles bring back a lot of memories. Thanks for sharing these highly informative write-ups.
1. Use lock_guard instead of unique_lock to avoid overhead. 2. Use lock instead of try_lock to reduce overhead. 3. Time and space optimizations come with trade-offs.