Mutex
Using mutex to protect data
In its simplest form, using a mutex consists of four straight-forward steps:
Include the
<mutex>
headerCreate an
std::mutex
Lock the mutex using
lock()
before read/write is calledUnlock the mutex after the read/write operation is finished using
unlock()
In order to protect the access to _vehicles
from being manipulated by several threads at once, a mutex has been added to the class as a private data member. In the pushBack
function, the mutex is locked before a new element is added to the vector and unlocked after the operation is complete.
Note that the mutex is also locked in the function printSize just before printing the size of the vector. The reason for this lock is two-fold: First, we want to prevent a data race that would occur when a read-access to the vector and a simultaneous write access (even when under the lock) would occur. And second, we want to exclusively reserve the standard output to the console for printing the vector size without other threads printing to it at the same time.
When this code is executed, 1000 elements will be in the vector. By using a mutex to our shared resource, a data race has been effectively avoided.
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.
Exercise
Please adapt the code from the previous example (example_2.cpp) in a way that a timed_mutex
is used. Also, in the function pushBack, please use the method try_lock_for
instead of lock, which should be executed until a maximum number of attempts is reached (e.g. 3 times) or until it succeeds. When an attempt fails, you should print an error message to the console that also contains the respective vehicle id and then put the thread to sleep for an amount of time before the next attempt is trief. Also, to expose the timing issues in this example, please introduce a call to sleep_for with a delay of several milliseconds before releasing the lock on the mutex. When done, experiment with the timing parameters to see how many vehicles will be added to the vector in the end.
Deadlock 1
Using mutexes can significantly reduce the risk of data races as seen in the example above. But 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.
Let us take a look at the following code example, which performs a division of numbers:
In this example, a number of tasks is started up in main()
with the method divideByNumber
as the thread function. Each task is given a different denominator and within divideByNumber a check is performed to avoid a division by zero. If denom should be zero, an exception is thrown. In the catch-block, the exception is caught, printed to the console and then the function returns immediately. The output of the program changes with each execution and might look like this:
As can easily be seen, the console output is totally mixed up and some results appear multiple times. There are several issues with this program, so let us look at them in turn:
First, the thread function writes its result to a global variable which is passed to it by reference. This will cause a data race as illustrated in the last section. The
sleep_for
function exposes the data race clearly.Second, the result is printed to the console by several threads at the same time, causing the chaotic output.
Exercise
As we have seen already, using a mutex can protect shared resources. So please modify the code in a way that both the console as well as the shared global variable result
are properly protected.
The problem you have just seen is one type of deadlock, which causes a program to freeze because one thread does not release the lock on the mutex while all other threads are waiting for access indefinitely. Let us now look at another type.
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. The thread and therefore usually the whole program is blocked forever. The following code illustrates the problem:
When the program is executed, it produces the following output:
Notice that it does not print the "Finished" statement nor does it return - the program is in a deadlock, which it can never leave.
Let us take a closer look at this problem:
ThreadA
and ThreadB
both require access to the console. Unfortunately, they request this resource which is protected by two mutexes in different order. If the two threads work interlocked so that first ThreadA
locks mutex 1, then ThreadB
locks mutex 2, the program is in a deadlock: Each thread tries to lock the other mutex and needs to wait for its release, which never comes. The following figure illustrates the problem graphically.
Exercise
One way to avoid such a deadlock would be to number all resources and require that processes request resources only in strictly increasing (or decreasing) order. Please try to manually rearrange the locks and unlocks in a way that the deadlock does not occur and the following text is printed to the console:
As you have seen, avoiding such a deadlock is possible but requires time and a great deal of experience. In the next section, we will look at ways to avoid deadlocks - both of this type as well as the previous type, where a call to unlock the mutex had not been issued.
Unique Lock
The problem with the previous example is that we can only lock the mutex once and the only way to control lock and unlock is by invalidating the scope of the std::lock_guard object. But what if we wanted (or needed) a finer control of the locking mechanism?
A more flexible alternative to std::lock_guard is unique_lock, that also provides support for more advanced mechanisms, such as deferred locking, time locking, recursive locking, transfer of lock ownership and use of condition variables (which we will discuss later). It behaves similar to lock_guard but provides much more flexibility, especially with regard to the timing behavior of the locking mechanism.
Let us take a look at an adapted version of the code from the previous section above:
In this version of the code, std::lock_guard
has been replaced with std::unique_lock
. As before, the lock object lck
will unlock the mutex in its destructor, i.e. when the function divideByNumber
returns and lck
gets out of scope. In addition to this automatic unlocking, std::unique_lock
offers the additional flexibility to engage and disengage the lock as needed by manually calling the methods lock()
and unlock()
. This ability can greatly improve the performance of a concurrent program, especially when many threads are waiting for access to a locked resource. In the example, the lock is released before some non-critical work is performed (simulated by sleep_for
) and re-engaged before some other work is performed in the critical section and thus under the lock again at the end of the function. This is particularly useful for optimizing performance and responsiveness when a significant amount of time passes between two accesses to a critical resource.
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.
Avoiding deadlocks with std::lock()
std::lock()
In most cases, your code should only hold one lock on a mutex at a time. Occasionally you can nest your locks, for example by calling a subsystem that protects its internal data with a mutex while holding a lock on another mutex, but it is generally better to avoid locks on multiple mutexes at the same time, if possible. Sometimes, however, it is necessary to hold a lock on more than one mutex because you need to perform an operation on two different data elements, each protected by its own mutex.
In the last section, we have seen that using several mutexes at once can lead to a deadlock, if the order of locking them is not carefully managed. To avoid this problem, the system must be told that both mutexes should be locked at the same time, so that one of the threads takes over both locks and blocking is avoided. That's what the std::lock()
function is for - you provide a set of lock_guard
or unique_lock
objects and the system ensures that they are all locked when the function returns.
In the following example, which is a version of the code we saw in the last section were std::mutex
has been replaced with std::lock_guard
.
Note that when executing this code, it still produces a deadlock, despite the use of std::lock_guard.
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.
As a rule of thumb, programmers should try to avoid using several mutexes at once. Practice shows that this can be achieved in the majority of cases. For the remaining cases though, using std::lock
is a safe way to avoid a deadlock situation.
Last updated