Move std::unique_lock to transfer lock ownership

Question | Jul 13, 2020 | hkumar 

lock move

Overview

A unique_lock can lock a mutex at the start of a block for mutual exclusion in RAII fashion. The owned mutex is unlocked as the unique_lock goes out of the scope:

std::mutex m;

void foo() {
 std::unique_lock<std::mutex> lock(m);
 //m is locked here
 //Protected. Only one thread can be active here.
 //m is unlocked when foo ends
}

A unique_lock in the above manner is not any different from its modest relative lock_guard. However, a unique_lock has more features than a lock_guard, specifically — deferred locking, try locking, unlocking without destruction, use with an std::condition_variable, and transfer of lock ownership.

In this article, we will discuss how a unique_lock can transfer the ownership of the held lock (mutex) via move-semantics.

Transfer of lock ownership

A unique_lock is the exclusive owner of a lock. At any time, only one unique_lock instance can own a specific mutex. Therefore a unique_lock cannot be copied.

But a unique_lock does not really have to own a lock. Therefore, it can be moved to transfer the ownership of the held lock to another scope. The compiler automatically moves a unique_lock if it is a prvalue returned from a function, or a unique_lock variable can be moved explicitly via std::move. Let's take a compelling example of the transfer of lock ownership.

Consider a Record class that contains an identifier and some data. Each Record object also includes a mutex for fine-grained protection. Many Record objects are stored in an associative container against their int identifier (id). Assume that the Record container is loaded once when the application starts, and therefore, it does not require any protection itself. Note that we are storing std::shared_ptr<Record> in the container for automatic and safer memory management; but we are not going to stress more on this detail in the interest of conciseness:

struct Record {
 //Constructors...
 int id;    
 //More data...    
 std::mutex mutex;
};

//Container of Records. It is loaded once when the application starts.
std::map<int, std::shared_ptr<Record>> records; 

A getAndPreprocess function finds, preprocesses, and returns a locked Record. The getAndPreprocess returns a locked Record through an access object — LockedRecord — that holds a unique_lock and the Record, as follows:

class LockedRecord {
 std::shared_ptr<Record> ptr;
 std::unique_lock<std::mutex> lock;

public:
 LockedRecord() = default;

 //Initializes Record pointer and the unique_lock
 LockedRecord(const std::shared_ptr<Record>& p):
  ptr(p), lock(p->mutex) {} 

 bool ownsRecord() const { return ptr != nullptr; }

 Record& record() const { 
  //throw an exception here if 'ptr' is null
  return *ptr; 
 }
};


LockedRecord getAndPreprocess(int id) {

 //Find Record
 auto it = records.find(id);
 if(it != records.end()) {
  //Found record

  //Create a LockedRecord, it acquires a lock
  LockedRecord lr{it->second};

  //Preprocess the record.

  //Return LockedRecord. It transfers the lock's ownership
  return lr;
 }
 //Record not found. Return an empty LockedRecord
 return {}; 
}

Notice that the getAndPreprocess returns an rvalue (prvalue to be precise), which gets moved to a variable in the caller. Therefore, the unique_lock in the returned LockedRecord also moves, which transfers the ownership of the held lock to the caller.

For example, a function — process — calls getAndPreprocess below. It checks if the returned LockedRecord object owns a Record before proceeding with further processing of the Record:

void process(int id) {
 //Fetch a preprocessed locked Record
 auto lr = getAndPreprocess(id);
 //Check if the LockedRecord owns a Record
 if(lr.ownsRecord()) {
  //Record is found, preprocessed, and locked.
  //Do something
  std::cout << lr.record().id << "\n";
 }
 //Record is unlocked here on return
}

A LockedRecord is like a gateway to access the wrapped Record. When the function process ends, the automatic LockedRecord (lr) is destroyed, thus unlocking the Record.

In the above example, the transfer of lock ownership allows us to modularize the finding and some standard preprocessing of the Records, separate from the more specific heavy processing.

Concept Check

Consider another version of Record processing where a function — processMany — processes multiple Record objects in a loop for a given list of identifiers. In each iteration of the loop, processMany finds a Record, locks it for some preprocessing, and hands it over to another thread for asynchronous processing.

We are using std::thread to start a thread that executes a provided lambda expression to process the Record. All the std::thread objects are created in a vector and joined before processMany ends.

The function processMany needs to transfer lock ownership to the lambda expression because releasing and reacquiring lock in the async thread would open a race-condition window. The lock ownership can be transferred in the below lambda expression's capture clause, which we have partially omitted (_____ below):

void processMany(std::vector<int> ids) {

 std::vector<std::thread> threads; //List of threads

 for(int id : ids) {   //Iterate over identifiers
  auto it = records.find(id);

  if(it != records.end()) { //Find a Record
   //Found record
   auto& rec =  it->second;
   //Acquire a lock
   std::unique_lock<std::mutex> lock(rec->mutex);

   //Do some preprocessing of Record 'rec' here

   //Transfer to another thread for more heavy processing
   //std::thread handle is created (emplaced) in the vector 
   //Requires C++14 (for lambda init-capture)
   threads.emplace_back([_____, r=rec](){ //_____ omitted intentionally
    //Process Record 'r'  here
   });
  }
 }

 //Join all async threads
 for(auto& t : threads)
   t.join();
}

Select the appropriate capture clause from below choices that would move or transfer the lock ownership to the async lambda expression. Note that we are using C++14 or above. Check Explanations for details:

2015 nextptr