C++11 threads, mutexes and condition variables

Article directory

  • foreword
  • 1. Create the first thread
  • 2. The life cycle, waiting and separation of thread objects
  • 3. Multiple ways to create threads
  • 4. Mutex
    • 4.1 Exclusive mutex std::mutex
    • 4.2 Recursive exclusive mutex recursive_mutex
    • 4.3 Mutex std::timed_mutex and std::recursive_timed_mutex with timeout
    • 4.4 std::lock_guard and std::unique_lock
  • 5. Use of call_once/once_flag
  • 6. Condition variables

Foreword

Before C++11, the C++ language did not provide language-level support for concurrent programming, which made it very inconvenient for us to write portable concurrent programs. Now C++11 has added threads and thread-related classes, which conveniently supports concurrent programming, and greatly improves the portability of writing multi-threaded programs

1. Create the first thread

//Creating a thread needs to introduce the header file thread
#include <thread>
#include <iostream>

void ThreadMain()
{<!-- -->
cout << "begin thread main" << endl;
}

int main()
{<!-- -->
//Create a new thread t and start
thread t(ThreadMain);
//The main thread (main thread) waits for t to finish executing
if (t.joinable()) // essential
{<!-- -->
//Wait for the child thread to exit
t.join(); // essential
}
return 0;
}

We all know that for a single thread, it is the main thread or called the main thread, and all the work is done by the main thread. In a multi-threaded environment, sub-threads can share the work pressure of the main thread, and realize real parallel operation under multiple CPUs.
In the above code, you can see that the main thread creates and starts a new thread t, and the new thread t executes the ThreadMain() function. The jion function will block the main thread until the execution of the new thread t ends. If the new thread t has a return value, the return value will be ignored

We can use the function this_thread::get_id() to judge whether the t thread or the main thread executes the task

void ThreadMain()
{<!-- -->
cout << "thread" <<this_thread::get_id()<< ":begin thread main" << endl;
}

int main()
{<!-- -->
//Create a new thread t and start
thread t(ThreadMain);
//The main thread (main thread) waits for t to finish executing
if (t.joinable()) // essential
{<!-- -->
//Wait for the child thread to exit
cout << "thread" <<this_thread::get_id() << ":waiting" << endl;
t.join(); // essential
}
return 0;
}

Results of the:

2. The life cycle, waiting and separation of thread objects

void func()
{<!-- -->
cout << "do func" << endl;
}

int main()
{<!-- -->
thread t(func);
return 0;
}

An exception may be thrown when the appeal code is running, because the thread object t may end before the thread function func, should ensure that the life cycle of the thread object still exists after the execution of the thread function func

In order to prevent the life cycle of the thread object from ending earlier than the thread function fun, you can use the thread to wait for the join

void func()
{<!-- -->
while (true)
{<!-- -->
cout << "do work" << endl;
this_thread::sleep_for(std::chrono::seconds(1));//The current thread sleeps for 1 second
}
}

int main()
{<!-- -->
thread t(func);
if (t. joinable())
{<!-- -->
t.join();//main thread blocking
}
return 0;
}

Although the use of join can effectively prevent the crash of the program, in some cases, we do not want the main thread to be blocked in place through join, and detach can be used to separate the thread. But it needs to be noted: After detach, the main thread can no longer communicate with the child thread. For example, after detach, it can no longer wait for the child thread through join, and we can’t control any child thread after execution

void func()
{<!-- -->
int count = 0;
while (count < 3)
{<!-- -->
cout << "do work" << endl;
count + + ;
this_thread::sleep_for(std::chrono::seconds(1));//The current thread sleeps for 1 second
}
}

int main()
{<!-- -->
thread t(func);
t. detach();
this_thread::sleep_for(std::chrono::seconds(1));//The current thread sleeps for 1 second
cout << "thread t separated successfully" << endl;
return 0;
}

Results of the:

3. Multiple ways to create threads

The creation and execution of a thread is nothing more than specifying an entry function for the thread. For example, the entry function of the main thread is the main() function. The entry function of the sub-thread written earlier is a global function. In addition to these, the entry function of the thread can also be function pointer, functor, class member function, lambda expression, etc., they all have a common feature: all are callable objects . The entry function of the thread is specified, which can be any callable object.

Ordinary functions are used as thread entry functions

void func()
{<!-- -->
cout << "hello world" << endl;
}

int main()
{<!-- -->
thread t(func);
if (t. joinable())
{<!-- -->
t. join();
}
return 0;
}

The member function of the class is used as the entry function of the thread

class ThreadMain
{<!-- -->
public:
ThreadMain() {<!-- -->}
virtual ~ThreadMain(){<!-- -->}
void SayHello(std::string name)
{<!-- -->
cout << "hello " << name << endl;
}
};

int main()
{<!-- -->
ThreadMain obj;
thread t( & amp;ThreadMain::SayHello, obj, "fl");
thread t1( &ThreadMain::SayHello, &obj, "fl");
t. join();
t1. join();
return 0;
}

There are differences between t and t1 when passing parameters:

  1. t is a statement to call the thread function with the object obj, that is, the thread function will run in the context of the obj object. Here obj is passed to the thread constructor by value, so a copy of the object obj is used in the thread. This method is suitable for the case where the class definition is in the local scope and needs to be passed to the thread.
  2. t1 is a statement that calls the thread function using the pointer & obj of the object, that is, the thread function will run in the context pointed to by the pointer of the object obj. The pointer to the object obj is used here, so the original obj object is used in the thread. This method is suitable for situations where a class definition needs to be passed to a thread when it is defined in a global or static scope.

If you need to create a thread in the member function of the class, use another member function in the class as the entry function, and then execute

class ThreadMain
{<!-- -->
public:
ThreadMain() {<!-- -->}
virtual ~ThreadMain(){<!-- -->}
void SayHello(std::string name)
{<!-- -->
cout << "hello " << name << endl;
}
void asycSayHello(std::string name)
{<!-- -->
thread t( & amp;ThreadMain::SayHello, this, name);
if (t. joinable())
{<!-- -->
t. join();
}
}
};

int main()
{<!-- -->
ThreadMain obj;
obj.asycSayHello("fl");
return 0;
}

In the member function of asycSayHello, if the this pointer is not passed, the compilation will fail


The reason is that the parameter list does not match, so we need to pass the this pointer explicitly, indicating the entry function that takes the member function of this object as a parameter

lambda expression as thread entry function

int main()
{<!-- -->
thread t([](int i){<!-- -->
cout << "test lambda i = " << i << endl;
}, 123);
if (t. joinable())
{<!-- -->
t. join();
}
return 0;
}

Results of the:

In the member function of the class, the lambda expression is used as the entry function of the thread

class TestLmadba
{<!-- -->
public:
void Start()
{<!-- -->
thread t([this](){<!-- -->
cout << "name is " <<this->name << endl;
});
if (t. joinable())
{<!-- -->
t. join();
}
}

private:
std::string name = "fl";
};

int main()
{<!-- -->
TestLmadba test;
test. Start();
return 0;
}

In the member function of the class, the lambda expression is used as the entry function of the thread. If you need to access the cashed member variable, you also need to pass the this pointer

The functor is used as the entry function of the thread

class Mybusiness
{<!-- -->
public:
Mybusiness(){<!-- -->}
virtual ~Mybusiness(){<!-- -->}

void operator()(void)
{<!-- -->
cout << "Mybusiness thread id is " <<this_thread::get_id() << endl;
}

void operator()(string name)
{<!-- -->
cout << "name is " << name << endl;
}
};

int main()
{<!-- -->
Mybusiness mb;
thread t(mb);
if (t. joinable())
{<!-- -->
t. join();
}
thread t1(mb, "fl");
if (t1. joinable())
{<!-- -->
t1. join();
}
return 0;
}

Results of the:


Thread t uses a functor without parameters as the function entry, while thread t1 uses a functor with parameters as the function entry

Function pointer as thread entry function

void func()
{<!-- -->
cout << "thread id is " <<this_thread::get_id() << endl;
}

void add(int a, int b)
{<!-- -->
cout << a << " + " << b << "=" << a + b << endl;
}

int main()
{<!-- -->
//Adopt the extended using of C++11 to define the function pointer type
using FuncPtr = void(*)();
using FuncPtr1 = void(*)(int, int);
//Use FuncPtr to define a function pointer variable
FuncPtr ptr = & func;
thread t(ptr);
if (t. joinable())
{<!-- -->
t. join();
}

FuncPtr1 ptr1 = add;
thread t1(ptr1, 1, 10);
if (t1. joinable())
{<!-- -->
t1. join();
}
return 0;
}

Results of the:

function and bind as the entry function of the thread

void func(string name)
{<!-- -->
cout <<this_thread::get_id() << ":name is " << name << endl;
}

int main()
{<!-- -->
function<void(string)> f(func);
thread t(f, "fl");
if (t. joinable())
{<!-- -->
t. join();
}
thread t1(bind(func, "fl"));
if (t1. joinable())
{<!-- -->
t1. join();
}
return 0;
}

Results of the:

Threads cannot be copied and copied, but they can be moved

//Assignment operation
void func(string name)
{<!-- -->
cout <<this_thread::get_id() << ":name is " << name << endl;
}

int main()
{<!-- -->
thread t1(func, "fl");
thread t2 = t1;
thread t3(t1);
return 0;
}

Compilation error:

Inside the thread, the assignment and copy operations of the thread have been deleted, so it cannot be called

//Move operation
void func(string name)
{<!-- -->
cout <<this_thread::get_id() << ":name is " << name << endl;
}

int main()
{<!-- -->
thread t1(func, "fl");
thread t2(std::move(t1));
if (t1. joinable())
{<!-- -->
t1. join();
}
if (t2. joinable())
{<!-- -->
t2. join();
}
return 0;
}

Results of the:

After the thread is moved, the thread object t1 will not represent any thread, which can be observed through debugging

4. Mutex

When multiple threads access the same shared resource at the same time, if it is not protected or any synchronization operation is not performed, data races or inconsistent states may occur, causing problems in program operation.
In order to ensure that all threads can access shared resources correctly, predictably, and without conflicts, C++11 provides mutexes.
Mutex is a synchronization primitive and a means of thread synchronization, which is used to protect shared data accessed by multiple threads at the same time. A mutex is what we usually call a lock
Mutexes with 4 semantics are provided in C++11

  • std::mutex: exclusive mutex, not recursive
  • std::timed_mutex: exclusive mutex with timeout, cannot be used recursively
  • std::recursive_mutex: recursive mutex, no timeout function
  • std::recursive_timed_mutex: recursive mutex with timeout

4.1 Exclusive mutex std::mutex

The interfaces of these mutexes are basically similar. The general usage is to block the thread through the lock() method until the ownership of the mutex is obtained. After the thread acquires the mutex and completes the task, it must use unlock() to release the mutex. lock() and unlock() must appear in pairs. try_lock() tries to lock the mutex, returns true if successful, false if it fails, it is non-blocking.

int num = 0;
std::mutex mtx;
void func()
{<!-- -->
for (int i = 0; i < 100; + + i)
{<!-- -->
mtx. lock();
num++;
mtx. unlock();
}
}

int main()
{<!-- -->
thread t1(func);
thread t2(func);
if (t1. joinable())
{<!-- -->
t1. join();
}
if (t2. joinable())
{<!-- -->
t2. join();
}
cout << num << endl;
return 0;
}

Results of the:

Using lock_guard can simplify the writing method of lock/unlock, and it is also safer, because lock_guard will automatically lock the mutex when it is constructed, and automatically unlock it when it is destructed after exiting the scope, thus ensuring the correct operation of the mutex. Avoid forgetting the unlock operation, so try to use lock_guard. lock_guard uses RALL technology, which allocates resources in the constructor of the class and releases resources in the destructor to ensure that the resources are released after they go out of scope. The above example will be more concise after using lock_guard, the code is as follows:

void func()
{<!-- -->
for (int i = 0; i < 100; + + i)
{<!-- -->
lock_guard<mutex> lock(mtx);
num++;
}
}

Generally speaking, when a thread finishes executing an operation, it releases the lock, and then needs to wait tens of milliseconds to let other threads also acquire the lock resource and perform the operation. If you don’t wait, the current thread may acquire the lock resource immediately after releasing the lock, which will cause other threads to starve.

4.2 Recursive exclusive mutex recursive_mutex

The recursive lock allows the same thread to acquire the mutex multiple times, which can be used to solve the problem of deadlock when the same thread needs to acquire the mutex multiple times. In the following code, a thread deadlocks when it acquires the same mutex multiple times:

class Complex
{<!-- -->
public:
std::mutex mtx;
void SayHello()
{<!-- -->
lock_guard<mutex> lock(mtx);
cout << "Say Hello" << endl;
SayHi();
}

void SayHi()
{<!-- -->
lock_guard<mutex> lock(mtx);
cout << "say Hi" << endl;
}
};

int main()
{<!-- -->
Complex complex;
complex. SayHello();
return 0;
}

Results of the:

When this example runs, a deadlock occurs because the mutex is obtained when SayHello is called, and the same mutex is obtained when SayHI is called later, but this mutex has been obtained by the current thread and cannot be released. At this time A deadlock will occur, causing the program to crash.
To solve the deadlock problem here, the easiest way is to use a recursive lock: std::recursive_mutex, which allows the same thread to acquire the mutex multiple times

class Complex
{<!-- -->
public:
std::recursive_mutex mtx;//The same thread can acquire the same mutex multiple times without deadlock
void SayHello()
{<!-- -->
lock_guard<recursive_mutex> lock(mtx);
cout << "Say Hello" << endl;
SayHi();
}

void SayHi()
{<!-- -->
lock_guard<recursive_mutex> lock(mtx);
cout << "say Hi" << endl;
}
};

Results of the:

It should be noted that it is better not to use recursive locks as much as possible. The main reasons are as follows:

1. The processing of multi-threaded mutexes that require recursive locking can often be simplified. Allowing recursive mutexes can easily indulge the generation of complex logic, rather than causing some obscure problems caused by multi-threaded synchronization.
2. The efficiency of recursive locks is lower than that of non-recursive locks
3. Although the recursive lock allows the same thread to obtain the same mutex multiple times, the maximum number of repetitions is not specified. Once a certain number of times is exceeded, a std::system error will be thrown if the lock is called again

4.3 Mutex std::timed_mutex and std::recursive_timed_mutex with timeout

std::timed_mutex is an exclusive lock with timeout, and srd::recursive_timed_mutex is a recursive lock with timeout, which is mainly used to increase the timeout lock waiting function when acquiring a lock, because sometimes you don’t know how long it takes to acquire a lock, in order not to keep waiting for mutual exclusion If the amount is limited, set a waiting timeout period, and other things can be done after the timeout period.
Compared with std::mutex, std::timed_mutex has two interfaces for timeout acquiring locks: try_lock_for and try_lock_until. These two interfaces are used to set the timeout time for acquiring mutexes. When using them, you can use a while loop to continuously acquire them. mutex.

std::timed_mutex mtx;

void work()
{<!-- -->
chrono::milliseconds timeout(100);
while (true)
{<!-- -->
if (mtx. try_lock_for(timeout))
{<!-- -->
cout <<this_thread::get_id() << ": do work with the mutex" << endl;
this_thread::sleep_for(chrono::milliseconds(250));
mtx. unlock();
}
else
{<!-- -->
cout <<this_thread::get_id() << ": do work without the mutex" << endl;
this_thread::sleep_for(chrono::milliseconds(100));
}
}
}

int main()
{<!-- -->
thread t1(work);
thread t2(work);
if (t1. joinable())
{<!-- -->
t1. join();
}
if (t2. joinable())
{<!-- -->
t2. join();
}
return 0;
}

Results of the:

In the above example, a while loop is used to continuously acquire the timeout lock. If the lock is not acquired after the timeout, it will sleep for 100 milliseconds, and then continue to acquire the lock.
Compared with std::timed_mutex, std::recursive_timed_mutex has more functions of recursive lock, allowing the same thread to acquire mutex multiple times. The usage of std::recursive_timed_mutex is similar to std::recursive_mutex, which can be seen as adding a timeout function on the basis of std::recursive_mutex

4.4 std::lock_guard and std::unique_lock

The functions of lock_guard and unique_lock are exactly the same. The main difference is that unique_lock is more flexible and can release mutex freely, while lock_guard needs to wait until the end of the life cycle to release.

Both of their constructors have a second parameter
unique_lock:

lock_guard:

You can see from the source code that in the constructor of unique_lock, there are three types of second parameters, namely adopt_lock, defer_lock and try_to_lock. In the constructor of lock_guard, there is only one type of second parameter, adopt_lock

The meanings of these parameters are:
adopt_lock: The mutex has been locked, and there is no need to lock it in the constructor (lock_guard and unique_lock are common)
defer_lock: I will lock the mutex by myself later, no need to lock in the constructor, just initialize an unlocked mutex
try_to_lock: The main function is to try to acquire the lock without blocking the thread. If the mutex is not currently locked, return the std::unique_lock object, which owns the mutex and has been locked . Returns an empty std::unique_lock object if the mutex is currently locked by another thread

mutex mtx;
void func()
{<!-- -->
//mtx.lock();//Locking is required, otherwise it will be automatically unlocked after the life cycle of the lock is over, which will cause the program to crash
unique_lock<mutex> lock(mtx, std::adopt_lock);
cout <<this_thread::get_id() << "do work" << endl;
}

int main()
{<!-- -->
thread t(func);
if (t. joinable())
{<!-- -->
t. join();
}
return 0;
}

Results of the:


adopt_lock means that when constructing unique_lock, it thinks that the mutex has already been locked, so it will not lock again. It gives us the authority and timing of locking, and we control it by ourselves

mutex mtx;
void func()
{<!-- -->
while (true)
{<!-- -->
unique_lock<mutex> lock(mtx, std::defer_lock);
cout << "func thread id is " <<this_thread::get_id() << endl;
this_thread::sleep_for(chrono::milliseconds(500));
}
}

int main()
{<!-- -->
thread t1(func);
thread t2(func);
if (t1. joinable())
{<!-- -->
t1. join();
}
if (t2. joinable())
{<!-- -->
t2. join();
}
return 0;
}

Results of the:


Originally, our intention was that only one thread can print “func thread id is…” at each moment of t1 and t2, but in fact there is a competition. The reason is that when defer_lock constructs unique_lock, it thinks that the mutex is in There will be a lock later, so there is no lock, so the printing result is confused, so we need to improve it manually

void func()
{<!-- -->
while (true)
{<!-- -->
unique_lock<mutex> lock(mtx, std::defer_lock);
lock. lock();
cout << "func thread id is " <<this_thread::get_id() << endl;
this_thread::sleep_for(chrono::milliseconds(500));
//lock.unlock(); //Can be added or not
//Because there is an internal standard, if we manually unlock it, due to the change of the flag bit, when calling the lock's destructor, the unlocking operation will not be performed
}
}

Results of the:

5. Use of call_once/once_flag

In order to ensure that a function is only called once in a multi-threaded environment, for example, when an object needs to be initialized, and this object can only be initialized once, you can use std::call_once to ensure that the function can only be called in a multi-threaded environment. is called once. When using std::call_once, a once_flag is required as the input parameter of call_once, and the usage is relatively simple

call_once function template

When using call_once, the first parameter is a flag bit of type once_flag, the second parameter is a callable object, and the third is a variable parameter, representing the parameters in the callable object

std::once_flag flag;

void do_once()
{<!-- -->
std::call_once(flag, [](){<!-- -->
cout << "call once" << endl;
});
}

int main()
{<!-- -->
const int ThreadSize = 5;
vector<thread> threads;
for (int i = 0; i <threadSize; + + i)
{<!-- -->
threads. emplace_back(do_once);
}
for (auto & t : threads)
{<!-- -->
if (t. joinable())
{<!-- -->
t. join();
}
}
return 0;
}

Results of the:

6. Condition variable

The condition variable is another synchronization mechanism for waiting provided by C++11. It can block one or more virtuous ministers and will not wake up the currently blocked thread until it receives a notification from another thread or times out. Condition variables need to be used in conjunction with mutexes. C++11 provides two kinds of condition variables:

  • condition_valuable, with std::unique for wait operation
  • condition_valuable_any, used with any mutex with lock and unlock semantics, is more flexible, but less efficient than condition_valuable

It can be seen that condition_valuable_any is more flexible than condition_valuable because it is universal and applicable to all locks, while condition_valuable has better performance. We should choose the appropriate condition variable according to the specific application scenario

Condition variables are used under the following conditions:

  1. The thread that owns the condition variable acquires the mutex
  2. Loop to detect a certain condition, if the condition is not met, block until the condition is met; if the condition is met, then execute downward
  3. Call notify_onc or notify_all to wake up one or all waiting threads after a thread satisfies the conditions and executes

A simple producer consumer model

mutex mtx;
condition_variable_any notEmpty;//Not full condition variable
condition_variable_any notFull;//Not empty condition variable
list<string> list_; //buffer
const int custom_threads_size = 3;//The number of consumers
const int produce_threads_size = 4;//The number of producers
const int max_size = 10;

void produce(int i)
{<!-- -->
while (true)
{<!-- -->
lock_guard<mutex> lock(mtx);
notEmpty.wait(mtx, []{<!-- -->
return list_.size() != max_size;
});
stringstream ss;
ss << "producer" << i << "produced stuff";
list_.push_back(ss.str());
notFull. notify_one();
}
}

void custom(int i)
{<!-- -->
while (true)
{<!-- -->
lock_guard<mutex> lock(mtx);
notFull.wait(mtx, []{<!-- -->
return !list_.empty();
});
cout << "consumer" << i << "consumed" << list_.front() << endl;
list_.pop_front();
notEmpty. notify_one();
}
}

int main()
{<!-- -->
vector<std::thread> producer;
vector<std::thread>customer;
for (int i = 0; i < produce_threads_size; + + i)
{<!-- -->
producer. emplace_back(produce, i);
}
for (int i = 0; i < custom_threads_size; + + i)
{<!-- -->
customer.emplace_back(custome, i);
}

for (int i = 0; i < produce_threads_size; + + i)
{<!-- -->
producer[i]. join();
}
for (int i = 0; i < custom_threads_size; + + i)
{<!-- -->
customer[i]. join();
}
return 0;
}

In the above case, list list_ is a critical resource. Whether it is producer production data or consumer consumption data, data must be inserted or deleted in list_, in order to prevent data competition or inconsistent state , which causes problems in the running of the program, because each operation of list_ needs to be locked.
When list_ is not full, the producer can produce data. If it is full, it will be blocked under the condition variable notFull, and the consumer needs to randomly wake up a producer through notify_one().
When list_ is not empty. The consumer can consume data, if it is empty, it will be blocked under the condition variable notEmpty, and the producer needs to randomly wake up a consumer through notify_one().