Assuming we have a piece of memory (e.g. a shared variable) that we want to protect from simultaneous access, we can assign a mutex to be the guardian of this particular memory. It is important to understand that a mutex is bound to the memory it protects. A thread 1 who wants to access the protected memory must "lock" the mutex first. After thread 1 is "under the lock", a thread 2 is blocked from access to the shared variable, it can not acquire the lock on the mutex and is temporarily suspended by the system.
Once the reading or writing operation of thread 1 is complete, it must "unlock" the mutex so that thread 2 can access the memory location. Often, the code which is executed "under the lock" is referred to as a "critical section". It is important to note that also read-only access to the shared memory has to lock the mutex to prevent a data race - which would happen when another thread, who might be under the lock at that time, were to modify the data.
When several threads were to try to acquire and lock the mutex, only one of them would be successful. All other threads would automatically be put on hol
In its simplest form, using a mutex consists of four straight-forward steps:
- Include the
<mutex>
header - Create an
std::mutex
- Lock the mutex using
lock()
before read/write is called - Unlock the mutex after the read/write operation is finished using
unlock()
Example:
class WaitingVehicles
{
public:
WaitingVehicles() {}
// getters / setters
void printSize()
{
_mutex.lock();
std::cout << "#vehicles = " << _vehicles.size() << std::endl;
_mutex.unlock();
}
// typical behaviour methods
void pushBack(Vehicle &&v)
{
_mutex.lock();
_vehicles.emplace_back(std::move(v)); // data race would cause an exception
_mutex.unlock();
}
private:
std::vector<Vehicle> _vehicles; // list of all vehicles waiting to enter this intersection
std::mutex _mutex;
};
In the following, a short overview of the different available mutex types is given:
mutex
: provides the core functions lock() and unlock() and the non-blocking try_lock() method that returns if the mutex is not available.recursive_mutex
: allows multiple acquisitions of the mutex from the same thread.timed_mutex
: similar to mutex, but it comes with two more methods try_lock_for() and try_lock_until() that try to acquire the mutex for a period of time or until a moment in time is reached.recursive_timed_mutex
: is a combination of timed_mutex and recursive_mutex.
Imagine what would happen if an exception was thrown while executing code in the critical section, i.e. between lock and unlock. In such a case, the mutex would remain locked indefinitely and no other thread could unlock it - the program would most likely freeze.
A second type of deadlock is a state in which two or more threads are blocked because each thread waits for the resource of the other thread to be released before releasing its resource. The result of the deadlock is a complete standstill.
To avoid deadlock due to exception thrown in between lock()
and unlock()
, we can use std::lock_guard
object which keeps an associated mutex locked during the entire object life time. The lock is acquired on construction and released automaticallyon destruction. This makes it impossible to forget unlocked when an exception is thrown.
The main advantages of using std::unique_lock<>
over std::lock_guard
are briefly summarized in the following. Using std::unique_lock
allows you to…
- …construct an instance without an associated mutex using the default constructor
- …construct an instance with an associated mutex while leaving the mutex unlocked at first using the deferred-locking constructor
- …construct an instance that tries to lock a mutex, but leaves it unlocked if the lock failed using the try-lock constructor
- …construct an instance that tries to acquire a lock for either a specified time period or until a specified point in time
Despite the advantages of std::unique_lock<>
and std::lock_guard
over accessing the mutex directly, however, the deadlock situation where two mutexes are accessed simultaneously (see the last section) will still occur.
In the following deadlock-free code, std::lock
is used to ensure that the mutexes are always locked in the same order, regardless of the order of the arguments. Note that std::adopt_lock
option allows us to use std::lock_guard
on an already locked mutex.
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mutex1, mutex2;
void ThreadA()
{
// Ensure that locks are always executed in the same order
std::lock(mutex1, mutex2);
std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock);
std::cout << "Thread A" << std::endl;
std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock);
}
void ThreadB()
{
std::lock(mutex1, mutex2);
std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock);
std::cout << "Thread B" << std::endl;
std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock);
}
void ExecuteThreads()
{
std::thread t1( ThreadA );
std::thread t2( ThreadB );
t1.join();
t2.join();
std::cout << "Finished" << std::endl;
}
int main()
{
ExecuteThreads();
return 0;
}