std::lock, std::trylock in C++

std::lock - cppreference.com

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, ...);        

  • It takes multiple std::mutex or std::timed_mutex or std::recursive_mutex references.
  • Locks all the provided mutexes simultaneously using a mechanism that avoids deadlocks.
  • If multiple threads call std::lock() on the same set of mutexes in different orders, it guarantees safe locking.


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:

  • 0 if all locks were acquired, or a positive value representing the index of the first lock that could not be acquired.
  • If the return value is non-zero, it means that the locks were not acquired, and you can decide how to handle the situation accordingly.

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:

  1. Deadlock Avoidance: std::lock ensures deadlock avoidance by acquiring locks in a predetermined order. If a thread cannot acquire all the locks immediately, it will block until it can acquire all of them atomically, preventing potential deadlocks.
  2. Simplicity and Readability: Using std::lock improves the readability of the code by explicitly expressing the intention of acquiring multiple locks simultaneously. It eliminates the need for manually writing complex lock acquisition logic, making the code more concise and maintainable.
  3. Exception Safety: std::lock guarantees exception safety by ensuring that all locks are properly acquired or released, even if an exception is thrown during the acquisition process. This helps maintain the integrity of shared resources and prevents resource leaks.
  4. Performance Optimization: std::lock can potentially improve performance in certain scenarios.

Considerations and Limitations:

  1. Lock Order: To avoid deadlocks, it is essential to acquire locks in a consistent and predetermined order throughout the application. Inconsistent lock order can still result in deadlocks, even when using std::lock.
  2. Shared Locks: std::lock is primarily designed for acquiring exclusive locks (e.g., std::mutex). If you need to acquire shared locks (e.g., std::shared_mutex), you should consider using other synchronization mechanisms like std::shared_lock.


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:

  1. When multiple locks need to be acquired, each thread first attempts to lock their respective mutexes using the std::unique_lock constructor. This is typically done outside the std::lock function.
  2. Once each thread has attempted to acquire their respective locks, they then call the std::lock function, passing the unique_lock objects as arguments.
  3. The std::lock function internally implements a deadlock avoidance algorithm to acquire the locks in a safe manner. It analyzes the state of each mutex and determines the order in which the locks need to be acquired.
  4. If acquiring the locks would lead to a potential deadlock (e.g., if two threads are waiting for each other's locks), the std::lock function puts one or more threads in a blocked state until the locks can be acquired safely.
  5. Once all the locks can be acquired without causing a deadlock, the std::lock function atomically acquires them and returns. At this point, each thread can proceed with its critical section, knowing that all necessary locks have been acquired.

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 :

  1. std::lock(),try_lock() locks multiple mutexes atomically and safely, preventing deadlocks.
  2. It should always be used when locking multiple mutexes in a multithreaded environment.
  3. Use std::lock_guard or std::unique_lock with std::adopt_lock for RAII-based locking.
  4. std::lock() improves thread safety and code maintainability.


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

  • std::adopt_lock tells std::lock_guard that the mutexes are already locked by std::lock().
  • This ensures automatic unlocking when the function exits.

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.

Link: std::unique_lock,lock_guard, & scoped_lock

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

  1. Convenience: std::scoped_lock is more convenient as it automatically locks and unlocks the mutexes, while std::lock requires manual management of the locking and unlocking process.
  2. Flexibility: std::lock provides more flexibility as it can be used with std::unique_lock to manually control the locking and unlocking of mutexes, whereas std::scoped_lock is a simpler, all-in-one solution.
  3. Usage: std::scoped_lock is ideal for scenarios where you need a straightforward way to manage multiple mutexes, while std::lock is better suited for more complex scenarios where you need finer control over the locking process.

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.

To view or add a comment, sign in

More articles by Amit Nadiger

  • Dvb-APIs

    The Linux Digital Video Broadcasting (DVB) APIs serve as the critical bridge between user applications and kernel-level…

  • Satellite Communication Basics w.r.t TV

    Main Highlights RF: Raw satellite signal (10.7-12.

  • Actor Design Pattern in Rust

    What Is the Actor Design Pattern? The Actor model is a concurrency design pattern in which: Each Actor runs in its own…

  • Mock and Stub in Android

    In Android unit testing, the ability to replace dependencies with test doubles is crucial for effective and isolated…

  • Integrating C and Rust

    Interfacing Rust with existing C code is one of the most common and powerful real-world uses of Rust: you get Rust’s…

  • Const generics

    Const generics in Rust allow us to use a value (like a number) as a generic parameter, in addition to types and…

  • Rust modules

    Referance : Modules - Rust By Example Rust uses a module system to organize and manage code across multiple files and…

  • HRTB - Higher-Ranked Trait Bounds

    As you know lifetimes are a core feature of the Rust language, which will enforce that references do not outlive the…

  • K8s basics

    What is Kubernetes? Kubernetes is an open-source container orchestration platform. It helps to deploy, manage, scale…

  • Atomics in Rust

    In computer science, Atomic is used to describe an operation that is indivisible: it is either fully completed, or it…

Others also viewed

Explore content categories