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.
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.
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: