📜 ⬆️ ⬇️

Threads, locks, and condition variables in C ++ 11 [Part 1]

The first part of this article will focus on threads and locks in C ++ 11, conditional variables in all their glory will be discussed in detail in the second part ...

Streams


In C ++ 11, work with threads is done using the methods of the std::thread class (available from the <thread> header file), which can work with regular functions, lambdas and functors. In addition, it allows you to pass any number of parameters to a stream function.
 #include <thread> void threadFunction() { // do smth } int main() { std::thread thr(threadFunction); thr.join(); return 0; } 

In this example, thr is an object representing the thread in which the threadFunction() function will be executed. A call to join blocks the calling thread (in our case, the main thread) until thr (or rather threadFunction() ) does its work. If the stream function returns a value, it will be ignored. However, the function can accept any number of parameters.
 void threadFunction(int i, double d, const std::string &s) { std::cout << i << ", " << d << ", " << s << std::endl; } int main() { std::thread thr(threadFunction, 1, 2.34, "example"); thr.join(); return 0; } 

Although you can pass any number of parameters, they were all passed by value. If you need to pass parameters to a function by reference, they must be wrapped in std::ref or std::cref , as in the example:
 void threadFunction(int &a) { a++; } int main() { int a = 1; std::thread thr(threadFunction, std::ref(a)); thr.join(); std::cout << a << std::endl; return 0; } 

The program prints to console 2. If you do not use std::ref , the result of the program will be 1.

In addition to the join method, you should consider another, a similar method - detach .
detach allows you to disconnect the stream from the object, in other words, to make it background. join can no longer be applied to detached threads.
 int main() { std::thread thr(threadFunction); thr.detach(); return 0; } 

It should also be noted that if the stream function throws an exception, it will not be caught by the try-catch block. Those. The following code will not work (it will work more precisely, but not as it was intended: without intercepting exceptions):
 try { std::thread thr1(threadFunction); std::thread thr2(threadFunction); thr1.join(); thr2.join(); } catch (const std::exception &ex) { std::cout << ex.what() << std::endl; } 

To pass exceptions between threads, you need to catch them as a stream function and store them somewhere in order to access them later.
 std::mutex g_mutex; std::vector<std::exception_ptr> g_exceptions; void throw_function() { throw std::exception("something wrong happened"); } void threadFunction() { try { throw_function(); } catch (...) { std::lock_guard<std::mutex> lock(g_mutex); g_exceptions.push_back(std::current_exception()); } } int main() { g_exceptions.clear(); std::thread thr(threadFunction); thr.join(); for(auto &e: g_exceptions) { try { if(e != nullptr) std::rethrow_exception(e); } catch (const std::exception &e) { std::cout << e.what() << std::endl; } } return 0; } 

Before moving on, I want to point out some useful functions provided by <thread> in the std::this_thread :

Locks


In the last example, I had to synchronize access to the g_exceptions vector to make sure that only one thread could insert a new item at a time. For this, I used a mutex and a lock on the mutex. The mutex is a basic synchronization element and in C ++ 11 is presented in 4 forms in the <mutex> header file:

I will give an example of using std::mutex with the help functions get_id() and sleep_for() mentioned earlier:
 #include <iostream> #include <chrono> #include <thread> #include <mutex> std::mutex g_lock; void threadFunction() { g_lock.lock(); std::cout << "entered thread " << std::this_thread::get_id() << std::endl; std::this_thread::sleep_for(std::chrono::seconds(rand()%10)); std::cout << "leaving thread " << std::this_thread::get_id() << std::endl; g_lock.unlock(); } int main() { srand((unsigned int)time(0)); std::thread t1(threadFunction); std::thread t2(threadFunction); std::thread t3(threadFunction); t1.join(); t2.join(); t3.join(); return 0; } 

The program should produce something like the following:
 entered thread 10144 leaving thread 10144 entered thread 4188 leaving thread 4188 entered thread 3424 leaving thread 3424 

Before accessing shared data, the mutex must be locked by the lock method, and after finishing working with shared data it must be unlocked.
')
The following example shows a simple thread-safe container (implemented on the basis of std::vector ), having add() methods for adding one element and addrange() for adding several elements.
Note : yet this container is not completely thread-safe for several reasons, including the use of va_args . Also, the dump() method should not belong to the container, but should be an autonomous function. The purpose of this example is to show the basic concepts of using mutexes, and not to make a full-fledged, error-free, thread-safe container.
 template <typename T> class container { std::mutex _lock; std::vector<T> _elements; public: void add(T element) { _lock.lock(); _elements.push_back(element); _lock.unlock(); } void addrange(int num, ...) { va_list arguments; va_start(arguments, num); for (int i = 0; i < num; i++) { _lock.lock(); add(va_arg(arguments, T)); _lock.unlock(); } va_end(arguments); } void dump() { _lock.lock(); for(auto e: _elements) std::cout << e << std::endl; _lock.unlock(); } }; void threadFunction(container<int> &c) { c.addrange(3, rand(), rand(), rand()); } int main() { srand((unsigned int)time(0)); container<int> cntr; std::thread t1(threadFunction, std::ref(cntr)); std::thread t2(threadFunction, std::ref(cntr)); std::thread t3(threadFunction, std::ref(cntr)); t1.join(); t2.join(); t3.join(); cntr.dump(); return 0; } 

When executing this program, deadlock will occur (deadlock, i.e., the blocked thread will remain to wait). The reason is that the container tries to get the mutex several times before it is released (calling unlock ), which is impossible. This is where std::recursive_mutex comes on stage, which allows you to get the same mutex several times. The maximum number of receiving mutexes is not defined, but if this number is reached, lock will throw an exception std :: system_error . Therefore, the solution to the problem in the code above (except for changing the implementation of addrange() so that lock and unlock not called) is to replace the mutex with std::recursive_mutex .
 template <typename T> class container { std::recursive_mutex _lock; // ... }; 

Now, the result of the program will be as follows:
 6334 18467 41 6334 18467 41 6334 18467 41 

You probably noticed that when you call threadFunction() , the same numbers are generated. This is because the void srand (unsigned int seed); function void srand (unsigned int seed); initializes the seed only for the main thread. In other threads, the pseudo-random number generator is not initialized and the same numbers are obtained each time.
Explicit locking and unlocking can lead to errors, for example, if you forget to unblock the stream or, conversely, there will be an incorrect order of locks - all this will cause deadlock. Std provides several classes and functions to solve this problem.
The wrapper classes allow you to consistently use a mutex in the RAII style with automatic locking and unlocking within a single block. These classes are:

With this in mind, we can rewrite the container class as follows:
 template <typename T> class container { std::recursive_mutex _lock; std::vector<T> _elements; public: void add(T element) { std::lock_guard<std::recursive_mutex> locker(_lock); _elements.push_back(element); } void addrange(int num, ...) { va_list arguments; va_start(arguments, num); for (int i = 0; i < num; i++) { std::lock_guard<std::recursive_mutex> locker(_lock); add(va_arg(arguments, T)); } va_end(arguments); } void dump() { std::lock_guard<std::recursive_mutex> locker(_lock); for(auto e: _elements) std::cout << e << std::endl; } }; 

One can argue that the dump() method must be constant, because it does not change the state of the container. Try to make it so and get an error when compiling:
 'std::lock_guard<_Mutex>::lock_guard(_Mutex &)' : cannot convert parameter 1 from 'const std::recursive_mutex' to 'std::recursive_mutex &' 

A mutex (regardless of the implementation form) must be retrieved and released, which implies the use of non-constant lock() and unlock() methods. Thus, the lock_guard argument cannot be a constant. The solution to this problem is to make the mutable , then the const specifier will be ignored and this will allow changing the state from constant functions.
 template <typename T> class container { mutable std::recursive_mutex _lock; std::vector<T> _elements; public: void dump() const { std::lock_guard<std::recursive_mutex> locker(_lock); for(auto e: _elements) std::cout << e << std::endl; } }; 

Wrap class constructors can take a parameter that defines a blocking policy:

They are announced as follows:
 struct defer_lock_t { }; struct try_to_lock_t { }; struct adopt_lock_t { }; constexpr std::defer_lock_t defer_lock = std::defer_lock_t(); constexpr std::try_to_lock_t try_to_lock = std::try_to_lock_t(); constexpr std::adopt_lock_t adopt_lock = std::adopt_lock_t(); 

In addition to “wrappers” for mutexes, std also provides several methods for locking one or more mutexes:

Here is a typical example of a deadlock: we have a container with elements and the exchange() function that swaps two elements of different containers. For thread safety, the function synchronizes access to these containers, getting the mutex associated with each container.
 template <typename T> class container { public: std::mutex _lock; std::set<T> _elements; void add(T element) { _elements.insert(element); } void remove(T element) { _elements.erase(element); } }; void exchange(container<int> &c1, container<int> &c2, int value) { c1._lock.lock(); std::this_thread::sleep_for(std::chrono::seconds(1)); //  deadlock c2._lock.lock(); c1.remove(value); c2.add(value); c1._lock.unlock(); c2._lock.unlock(); } 

Suppose that this function is called from two different threads, from the first thread: the element is removed from 1 container and added to 2, from the second stream, on the contrary, the element is removed from 2 container and added to 1. This may cause a deadlock (if the context of the thread switches from one thread to another, immediately after the first lock).
 int main() { srand((unsigned int)time(NULL)); container<int> cntr1; cntr1.add(1); cntr1.add(2); cntr1.add(3); container<int> cntr2; cntr2.add(4); cntr2.add(5); cntr2.add(6); std::thread t1(exchange, std::ref(cntr1), std::ref(cntr2), 3); std::thread t2(exchange, std::ref(cntr2), std::ref(cntr1), 6); t1.join(); t2.join(); return 0; } 

To solve this problem, you can use std::lock , which guarantees locking in a safe (in terms of deadlock) way:
 void exchange(container<int> &c1, container<int> &c2, int value) { std::lock(c1._lock, c2._lock); c1.remove(value); c2.add(value); c1._lock.unlock(); c2._lock.unlock(); } 

Continued : conditional variables

Source: https://habr.com/ru/post/182610/


All Articles