Skip to content

Latest commit

 

History

History
151 lines (95 loc) · 5.64 KB

14.MutexesAndLocks.md

File metadata and controls

151 lines (95 loc) · 5.64 KB

Mutexes and Locks

The mutex entity

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

mutex

Using mutex to protect data

In its simplest form, using a mutex consists of four straight-forward steps:

  1. Include the <mutex> header
  2. Create an std::mutex
  3. Lock the mutex using lock() before read/write is called
  4. 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;
};

Using timed_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.

Deadlock 1

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.

Deadlock 2

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.

deadlock2

Using Locks to Avoid Deadlocks

Lock Guard

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.

Unique Lock

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…

  1. …construct an instance without an associated mutex using the default constructor
  2. …construct an instance with an associated mutex while leaving the mutex unlocked at first using the deferred-locking constructor
  3. …construct an instance that tries to lock a mutex, but leaves it unlocked if the lock failed using the try-lock constructor
  4. …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.

Avoiding deadlocks with std::lock()

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;
}