C++ concurrent programming (5): std::unique_lock, mutex ownership transfer, lock granularity

std::unique_lock<>flexible locking

Reference Blog

Sharing data between threads – using mutexes to protect shared data

C ++ multi-threaded unique_lock detailed explanation

Multithreaded programming (5) – unique_lock

Compared with std::lock_guard, std::unqiue_lock is not directly related to the data type of the mutex, so it is more flexible to use

It can pass in additional parameters during construction, such as std::adopt_lock and std::defer_lock, etc.

The time and space performance of std::unqiue_lock is inferior to std::lock_guard, which is the price it pays for flexibility

There are certain flags in std::unqiue_lock to indicate whether its instance owns a specific mutex. Obviously, these flags need to occupy space, and the checking and updating of flags also take time

std::adopt_lock parameter

  • The std::adopt_lock parameter indicates that the mutex has been locked, and there is no need to repeat the lock
  • The mutex must have been locked before this parameter can be used

std::try_to_lock parameter

  • It can avoid some unnecessary waiting, and will judge whether the current mutex can be locked. If it cannot be locked, you can execute other codes first.
  • This is different from adopt, you don’t need to lock yourself in advance

For example, if a thread is locked and the execution time is very long, then another thread will generally be blocked there, which will cause a waste of time. Then after using try_to_lock, if it is locked, it will not block and wait there, it can execute other codes that are not locked first

#include <iostream>
#include <mutex>

std::mutex mlock;

void work1(int & amp; s) {<!-- -->
for (int i = 1; i <= 5000; i ++ ) {<!-- -->
std::unique_lock<std::mutex> munique(mlock, std::try_to_lock);
if (munique. owns_lock() == true) {<!-- -->
s + = i;
}
else {<!-- -->
// Execute some code without shared memory
}
}
}

void work2(int & amp; s) {<!-- -->
for (int i = 5001; i <= 10000; i ++ ) {<!-- -->
std::unique_lock<std::mutex> munique(mlock, std::try_to_lock);
if (munique. owns_lock() == true) {<!-- -->
s + = i;
}
else {<!-- -->
// Execute some code without shared memory
}
}
}

int main()
{<!-- -->
int ans = 0;
std::thread t1(work1, std::ref(ans));
std::thread t2(work2, std::ref(ans));
t1. join();
t2. join();
std::cout << ans << std::endl;
return 0;
}

std::defer_lock parameter

  • This parameter means not to lock for the time being, and then manually lock it, but it is not allowed to lock before use
  • Generally used with unique_lock member functions to use
#include <iostream>
#include <mutex>

std::mutex mlock;

void work1(int & amp; s) {<!-- -->
for (int i = 1; i <= 5000; i ++ ) {<!-- -->
std::unique_lock<std::mutex> munique(mlock, std::defer_lock);
munique. lock();
s + = i;
munique.unlock(); // You don't need to unlock here, you can unlock it through the destructor of unique_lock
}
}

void work2(int & amp; s) {<!-- -->
for (int i = 5001; i <= 10000; i ++ ) {<!-- -->
std::unique_lock<std::mutex> munique(mlock, std::defer_lock);
munique. lock();
s + = i;
munique. unlock();
}
}

int main()
{<!-- -->
int ans = 0;
std::thread t1(work1, std::ref(ans));
std::thread t2(work2, std::ref(ans));
t1. join();
t2. join();
std::cout << ans << std::endl;
return 0;
}

When the defer_lock parameter is used, the unique_lock object will not be automatically locked when the unique_lock object is created, so you need to use the lock member function to manually lock it, and of course unlock to manually unlock it. This is the same as the lock and unlock method of mutex

try_lock( ) member function

Similar to the try_to_lock parameter

void work1(int & amp; s) {<!-- -->
for (int i = 1; i <= 5000; i ++ ) {<!-- -->
std::unique_lock<std::mutex> munique(mlock, std::defer_lock);
if (munique. try_lock() == true) {<!-- -->
s + = i;
}
else {<!-- -->
// handle some code without shared memory
}
}
}

release( ) member function

  • Release the link between unique_lock and mutex object, and return the **pointer** of the original mutex object
  • If the previous mutex has been locked, you need to manually unlock it later
void work1(int & amp; s) {<!-- -->
for (int i = 1; i <= 5000; i ++ ) {<!-- -->
std::unique_lock<std::mutex> munique(mlock); // Here is the automatic lock
std::mutex *m = munique. release();
s + = i;
m->unlock();
}
}

Transfer of mutex ownership between domains

Since std::unique_lock does not have a mutex associated with itself, ownership of the mutex can be transferred between different instances. std::unique_lock is a standard `move only object’

Mutex ownership transfer is very common, such as locking the mutex in a function, and then transferring its ownership to the caller to ensure that it can perform additional operations within the protection scope of the lock

std::unique_lock<std::mutex> get_lock() {<!-- -->
  extern std::mutex some_mutex;
  std::unique_lock<std::mutex> lk(some_mutex);
  prepare_data();
  return lk;
}

void process_data() {<!-- -->
  std::unique_lock<std::mutex> lk(get_lock());
  do_something();
}

For unique_lock objects, an object can only correspond to a mutex lock uniquely, and there cannot be one-to-many or many-to-one situations, otherwise deadlocks will occur

So if you want to pass the permission of two unique_lock objects to mutex, you need to use move semantics or move constructor

Note that unique_lock and lock_guard cannot be copied, lock_guard cannot be moved, but unique_lock can

// unique_lock can be moved, not copied
std::unique_lock<std::mutex> guard1(_mu);
std::unique_lock<std::mutex> guard2 = guard1; // error
std::unique_lock<std::mutex> guard2 = std::move(guard1); // ok

// lock_guard cannot be moved, cannot be copied
std::lock_guard<std::mutex> guard1(_mu);
std::lock_guard<std::mutex> guard2 = guard1; // error
std::lock_guard<std::mutex> guard2 = std::move(guard1); // error

The granularity of the lock

The granularity of the lock is a hand-waving term used to describe the amount of data protected by a lock

A fine-grained lock (a fine-grained lock) can protect a small amount of data

A coarse-grained lock (a coarse-grained lock) can protect a large amount of data

In short, we should choose the appropriate granularity according to the size of the data to be protected, if it is too large, it will be too much, and if it is too small, it will not be enough to protect

Mutex locks ensure the synchronization between threads, but turn parallel operations into serial operations, which has a great impact on performance, so we need to reduce the locked area as much as possible, That is, use fine-grained locks

Lock_guard is not good at this point, it is not flexible enough, lock_guard can only guarantee that the unlock operation is performed during destructuring, lock_guard itself does not provide an interface for locking and unlocking, but sometimes there is such a need

class LogFile {<!-- -->
    std::mutex_mu;
    ofstream f;
public:
    LogFile() {<!-- -->
        f.open("log.txt");
    }
    ~LogFile() {<!-- -->
        f. close();
    }
    void shared_print(string msg, int id) {<!-- -->
        {<!-- -->
            std::lock_guard<std::mutex> guard(_mu);
            //do something 1
        }
        //do something 2
        {<!-- -->
            std::lock_guard<std::mutex> guard(_mu);
            // do something 3
            f << msg << id << endl;
            cout << msg << id << endl;
        }
    }

};

In the above code, there are two pieces of code inside a function that need to be protected. At this time, using lock_guard requires the creation of two local objects to manage the same mutex (in fact, only one can be created, but the lock is too powerful and the efficiency No), the modification method is to use unique_lock. It provides lock() and unlock() interfaces, which can record whether it is locked or unlocked. When it is destructed, it will decide whether to unlock according to the current state (lock_guard will definitely unlock)

class LogFile {<!-- -->
    std::mutex_mu;
    ofstream f;
public:
    LogFile() {<!-- -->
        f.open("log.txt");
    }
    ~LogFile() {<!-- -->
        f. close();
    }
    void shared_print(string msg, int id) {<!-- -->

        std::unique_lock<std::mutex> guard(_mu);
        //do something 1
        guard.unlock(); //Temporary unlock

        //do something 2

        guard.lock(); //Continue to lock
        // do something 3
        f << msg << id << endl;
        cout << msg << id << endl;
        // At the end of the destruct guard will be temporarily unlocked
        // This sentence is optional, if you don't write it, it will be executed automatically when it is destructed
        // guard. ulock();
    }

As can be seen from the above code, when there is no need to lock the operation, the lock can be temporarily released first, and then when the protection needs to be continued, the lock can be continued, so that there is no need to repeatedly instantiate the lock_guard object, and the locked area can also be reduced. Similarly, you can use std::defer_lock to set Do not perform the default lock operation during initialization

void shared_print(string msg, int id)
{<!-- -->
    std::unique_lock<std::mutex> guard(_mu, std::defer_lock);
    //do something 1

    guard. lock();
    // do something protected
    guard.unlock(); //Temporary unlock

    //do something 2

    guard.lock(); //Continue to lock
    // do something 3
    f << msg << id << endl;
    cout << msg << id << endl;
    // At the end of the destruct guard will be temporarily unlocked
}

This is more flexible to use than lock_guard! Then there is a price, because it needs to maintain the state of the lock internally, so the efficiency is a little lower than lock_guard. When lock_guard can solve the problem, use lock_guard, otherwise, use unique_lock

Later, when learning condition variables, unique_lock will also come in handy