std::mutex

A mutex is an object that is used to guard a shared resource to ensure the use of the shared resource does not result in corruption. To accomplish this, std::mutex has a lock() function and an unlock() function. The lock function acquires access to a shared resource (sometimes referred to as a critical section). unlock() releases this previously acquired access. Any attempt to execute the lock() function after another thread has already executed lock() will result in the thread having to wait until the unlock() function is executed.

How std::mutex is implemented depends on the CPU's architecture and the operating system; however, in general, a mutex can be implemented with a simple integer. If the integer is 0, the lock() function will set the integer to 1 and return, which tells the mutex that it is acquired. If the integer is 1, meaning the mutex is already acquired, the lock() function will wait (that is, block) until the integer becomes 0, and then it will set the integer to 1 and return. How this wait is implemented depends on the operating system. For example, the wait() function can loop forever until the integer becomes 0, which is called a spinlock, or it can execute a sleep() function and wait for a period of time, allowing other threads and processes to execute while the mutex is locked. The release function always sets the integer to 0, meaning the mutex is no longer acquired. The trick to ensuring the mutex works properly is to ensure the integer is read/written using atomic operations. If non-atomic operations are used, the integer itself would suffer the same shared resource corruption the mutex is trying to prevent.

For example, consider the following:

#include <mutex>
#include <thread>
#include <string>
#include <iostream>

std::mutex m{};

void foo()
{
static std::string msg{"The answer is: 42 "};
while(true) {
m.lock();
for (const auto &c : msg) {
std::clog << c;
}
m.unlock();
}
}

int main(void)
{
std::thread t1{foo};
std::thread t2{foo};

t1.join();
t2.join();

// Never reached
return 0;
}

This example, when run, outputs the following:

In the preceding example, we create the same function that outputs to stdout. The difference is, before we output to stdout, we acquire std::mutex by executing the lock() function. Once we are done outputting to stdout, we release the mutex by executing the unlock() function. The code in between the lock() and unlock() functions is called the critical region. Any code in the critical region can only be executed by one thread at any given time, ensuring our use of stdout does not become corrupt.

Ensuring shared resources do not become corrupt by controlling access to the shared resource (for example, using a mutex) is called synchronization. Although the majority of scenarios where thread synchronization is needed are not complicated, some scenarios can result in thread synchronization schemes that require an entire college course to cover. For this reason, thread synchronization is considered an extremely difficult paradigm in computer science to program correctly.

In this recipe, we will cover some of these scenarios. To start, let's discuss something called a deadlock. A deadlock occurs when a thread enters an endless wait state when calling the lock() function. A deadlock is often extremely difficult to debug and is the result of several reasons, including the following:

  • A thread never calling unlock() due to programmer error or the thread that acquired the mutex crashing
  • The same thread calling the lock() function more than once before it calls unlock()
  • Each thread locking more than one mutex in a different order

To demonstrate this, let's look at the following example:

#include <mutex>
#include <thread>

std::mutex m{};

void foo()
{
m.lock();
}

int main(void)
{
std::thread t1{foo};
std::thread t2{foo};

t1.join();
t2.join();

// Never reached
return 0;
}

In the preceding example, we create two threads, both of which attempt to lock the mutex but never call unlock(). As a result, the first thread acquires the mutex and then returns without releasing it. When the second thread attempts to acquire the mutex, it is forced to wait for the first thread to execute unlock(), which it never does, resulting in a deadlock (that is, the program never returns).

Deadlock, in this example, is simple to identify and correct; however, in real-world scenarios, identifying deadlock is a lot more complicated. Let's look at the following example:

#include <array>
#include <mutex>
#include <thread>
#include <string>
#include <iostream>

std::mutex m{};
std::array<int,6> numbers{4,8,15,16,23,42};

int foo(int index)
{
m.lock();
auto element = numbers.at(index);
m.unlock();

return element;
}

int main(void)
{
std::cout << "The answer is: " << foo(5) << ' ';
return 0;
}

In the preceding example, we wrote a function that returns an element in an array, given an index. In addition, we acquire a mutex that guards the array and releases the mutex just before returning. The challenge here is that we have to unlock() the mutex where the function can return, which includes not only every possible branch that returns from the function, but all possible scenarios where an exception could be thrown. In the preceding example, if the index that is provided is larger than the array, the std::array object will throw an exception, resulting in the function returning before the function has a chance to call unlock(), which would result in deadlock if another thread is sharing this array.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset