Understanding C++ lock() and try_lock() Functions

1 Deadlock

std::mutex mtM,mtN;int M = 0;int N = 0;
void threadA() {    std::lock_guard guardM(mtM);      std::lock_guard guardN(mtN);       M++;    N--;}
void threadB() {    std::lock_guard guardN(mtN);      std::lock_guard guardM(mtM);       M--;    N++;}

This is a typical example that can lead to a deadlock. The classic two-phase locking issue is immediately apparent, but when more locks are involved, the problem of deadlock prevention becomes very important. In this case, C++17 introduced a new type of guard lock, std::scoped_lock, which has been discussed in the article “Various Locks in C++”. In fact, C++11 already provided such a mechanism, which is the focus of this article: the std::lock() and std::try_lock() functions, with std::scoped_lock serving as a user-friendly wrapper for them.

2 std::lock()

The purpose of the std::lock() function is to lock a given set of lockable objects using an algorithm that avoids deadlocks, thus preventing deadlock situations. The so-called lockable objects are those that implement the BasicLockable definition with `lock()` and `unlock()` methods, as well as the extended `try_lock()` method. Therefore, the std::lock() function can be used with various std::mutex objects, std::unique_lock, std::shared_lock, and user-defined lockable objects. It is important to note that the C++ standard only states that this function will lock these lockable objects in an order that avoids deadlocks, but it does not specify which deadlock avoidance algorithm is used, nor does it explain how to avoid excessive constraints; these are left to the library implementers.

In practice, many use cases for std::lock() can be replaced by std::scoped_lock. Here, we mainly introduce how it can be used in conjunction with the adopt_lock strategy of guard locks to achieve multi-phase locking that prevents deadlocks. The main issue with std::lock() is that it only handles locking safely and does not manage unlocking, so it is generally used in conjunction with various guard locks. One common usage is to first lock the mutex using std::lock() and then use the adopt_lock strategy of various guard locks to implement the unlocking, as shown in this bank transfer example:

struct bank_account {    explicit bank_account(int balance) : balance(balance) {}    int balance;    std::mutex m;};
void transfer(bank_account& from, bank_account& to, int amount) {    if (&from == &to) return; // avoid deadlock in case of self transfer
    // lock both mutexes without deadlock    std::lock(from.m, to.m);    // make sure both already-locked mutexes are unlocked at the end of scope    std::lock_guard<std::mutex> lock1(from.m, std::adopt_lock);    std::lock_guard<std::mutex> lock2(to.m, std::adopt_lock); //
    from.balance -= amount;    to.balance += amount;}

Another common usage is to first create guard locks using the defer_lock strategy and then use std::lock() on the guard locks. The previous transfer() function can be modified as follows:

void transfer(bank_account& from, bank_account& to, int amount) {    if (&from == &to) return; // avoid deadlock in case of self transfer
    std::unique_lock<std::mutex> lock1(from.m, std::defer_lock);    std::unique_lock<std::mutex> lock2(to.m, std::defer_lock);    std::lock(lock1, lock2);
    from.balance -= amount;    to.balance += amount;}

In this case, the guard locks must support lock and unlock, so guard locks like std::lock_guard cannot be used in this way, and of course, std::lock_guard does not support the defer_lock strategy.

3 std::try_lock()

The purpose of std::try_lock() is similar to that of std::lock(); it is also used to safely acquire a series of locks while avoiding deadlocks. The principle of std::lock() is to lock all BasicLockable objects in the order specified by the algorithm, blocking the current thread when a lock cannot be obtained until all BasicLockable objects are successfully locked. The prototype of std::try_lock() is as follows:

template< class Lockable1, class Lockable2, class... LockableN>int try_lock( Lockable1& lock1, Lockable2& lock2, LockableN&... lockn);

std::try_lock() sequentially numbers each lockable object in the sequence starting from 0 according to the order given by the parameters and calls their try_lock() methods to lock them. If a lockable object fails to lock, it unlocks the previously locked objects in sequence and returns the index of the failed lockable object. If all lockable objects are successfully locked, it returns -1. Even if an exception occurs internally in std::try_lock(), it guarantees that the already locked lockable objects will be unlocked.

std::try_lock() is actually a classic deadlock prevention strategy, which always attempts to acquire all locks; if a lock cannot be obtained, it releases the previously acquired locks. Users can choose to retry after a delay or give up.

4 Conclusion

The series of articles “Various mutexes in C++”, “Various locks in C++”, and “C++ lock() and try_lock() functions” introduced the main content of the C++ :mutex library, including the two updates in C++14 and C++17. These contents can meet various scenarios in multithreaded programming and reduce the complexity of using C++ for multithreaded programming. Regarding synchronization between threads, C++20 introduced semaphores, which will be discussed in other series. As mentioned earlier, many scenarios can be replaced by std::scoped_lock, which is indeed more convenient to use.

The common strategy for preventing deadlocks with std::lock() and std::scoped_lock is to attempt to acquire all locks at once; if unsuccessful, release the already acquired locks and retry until successful. Therefore, these two functions have a large granularity, and in cases of accessing multiple resources where efficiency is prioritized, they may not be suitable, and other deadlock prevention strategies are generally chosen. Finally, after std::lock(), the user must handle the unlocking, but as long as other places also use std::lock() or std::scoped_lock, the order of releasing locks does not matter. std::scoped_lock automatically releases locks in its destructor, and the standard does not specify the order of release, only that it should be handled in a way that does not cause deadlocks. However, as long as a consistent strategy is followed in all places using locks (i.e., acquiring all locks before starting work), the order of releasing locks can be arbitrary.

References

[1] Marc Gregoire, Professional C++ (Fifth Edition), John Wiley & Sons, Inc., 2021

[2] Scott Meyers, Effective Modern C++, O’Reilly, 2015

[3] Anthony Williams, C++ Concurrency in Action, Manning, 2019

[4] https://en.cppreference.com/w/cpp/thread/lock_guard.html

[5] https://en.cppreference.com/w/cpp/thread/lock_tag_t.html

[6] Nicolai M. Josuttis, C++20 – The Complete Guide, http://leanpub.com/cpp20′

[7] Jacek Galowicz. C++17 STL Cookbook. Packtpub. 2017

[8] ISO/IEC 14882:2020 §32.5.5/Generic locking algorithms [thread.lock.algorithm]

Information, code, and past articlesVarious locks in C++Various std::mutex in C++C++ spanC++ copy elisionC++ time library part eight: format and formattingC++20 PIC++ [[noreturn]] specifierHow C++ declares lambda as friendsC++ cache line interfaceC++ clamp functionC++ three and five rulesC++ strict aliasing rulesC++ construct_at functionContent and notification summary

Leave a Comment