【Example】C++ std::thread 及 std::mutex
与 Unix 下的 thread 不同的是,C++ 标准库当中的 std::thread 功能更加简单,可以支持跨平台特性。
因此在项目需要跨平台及对多线程简单应用情况下,应优先考虑使用 std::thread。
同时为了使多线程操作更加安全,std::thread 经常与标准库互斥量 std::mutex 配合使用。
std::thread
std::thread 对象是 C++ 标准库当中最基本的多线程实现方式。
可以使用
thread
对象查看和管理应用程序中的执行线程。 使用thread
默认构造函数创建的 对象不与任何执行线程相关联。 使用thread
可调用对象构造的 对象将创建一个新的执行线程,并调用该 中的可调用对象thread
。Thread
对象可以移动,但不能复制。 这就是执行线程只能与一个对象关联thread
的原因。每个执行线程都具有
thread::id
类型的唯一标识符。 函数this_thread::get_id
返回调用线程的标识符。 成员函数thread::get_id
返回由 对象管理的线程的标识符thread
。thread::
this_thread::get_id、
thread::get_id
对于默认构造的对象,该方法返回一个对象,该对象的值对于所有默认构造的对象都相同,并且不同于在调用时可以联接的任何执行线程返回的值。-- Microsoft Docs
std::thread 的头文件是:
#include <thread>
它的语法是:
【伪代码】std::thread t(FuncPtr, args1, ...); 【常规情况】std::thread t1(SortVectorMutex, std::ref(m), std::ref(vec1));
可以看到 std::thread 第一个参数为一个函数指针,后面则是该函数的参数。
当 std::thread 对象被初始化后,线程便立即开始执行。请注意是线程对象被初始化后,当使用默认空构造函数创建对象后,线程并没有被初始化,因此不会开始新的线程。
std::thread 的构造函数:
构造函数 | 操作 | 是否初始化 |
thread() noexcept; | 默认空构造函数 | 否 |
template <class Fn, class... Args> explicit thread(Fn&& fn, Args&&... args); | 初始化构造函数 | 是 |
thread(thread&& x) noexcept; | 移动构造函数 | 视情况而定 |
额外备注:
1,std::thread 禁用了拷贝构造函数(thread(const thread&) = delete),无法被拷贝构造。
2,std::thread 禁用了拷贝赋值重载(thread& operator=(const thread&) = delete),无法被拷贝赋值。
3,std::thread 可以被移动赋值:
thread& operator=(thread&& rhs) noexcept; std::thread t3(PrintID); std::thread t4 = std::move(t3);
4,std::thread 的移动构造本身不会对其进行初始化,如果被移动的对象本身是已初始化过的,那么它也是初始化的,反之亦然。
std::thread 的成员函数:
成员函数名 | 作用 |
join | 阻塞等待到该线程结束。 |
detach | 将线程从父进程分离,无法再通过 thread 对象对其进行操作,生命周期也脱离父进程,最终由操作系统进行资源回收。 |
joinable | 检查线程是否可被阻塞等待。 |
get_id | 获取该线程的唯一标识符。 |
swap | 与指定 thread 对象进行互换操作。 |
native_handle | 获取该线程的句柄。 |
hardware_concurrency | 返回逻辑处理器数量。 |
以下展示了一个 std::thread 的最简单应用:
void PrintID() { cout << "Thread ID: " << std::this_thread::get_id() << endl; return; } void SortVector(vector<int> &vec) { std::sort(vec.begin(), vec.end()); return; } int main() { cout << "Concurrency: " << std::thread::hardware_concurrency() << endl; std::thread t1(PrintID); std::thread t2(PrintID); std::thread t3(PrintID); vector<int> vec1{2, 1, 4, 8, 7, 5, 9, 3}; std::thread t4(SortVector, ref(vec1)); t4.join(); for (auto& i : vec1) { cout << i << endl; } t1.join(); t2.join(); t3.join(); return EXIT_SUCCESS; }
互斥量
在多线程操作当中,必然会出现对资源的并发访问,如果资源本身会因为多个线程同时操作而导致损坏不可用,这时就需要用到互斥量进行保护避免对该资源同时操作,也就是俗称的“锁”。
C++ 标准库当中提供了互斥量 mutex 系列,且在实际开发当中经常与 std::lock_guard 和 std::unique_lock 配合使用。
但是,要想学会使用 std::lock_guard 和 std::unique_lock ,必须先了解基本的 std::mutex。
头文件:
#include <mutex>
在头文件当中提供了四种互斥量:
名称 | 作用 |
std::mutex | 基本互斥量 |
std::timed_mutex | 定时互斥量 |
std::recursive_mutex | 递归互斥量 |
std::recursive_timed_mutex | 定时递归互斥量 |
std::mutex 与 std::timed_mutex
先从最基本的 std::mutex 入手,其余互斥量皆是其变种。需要了解4个公共方法:
名称 | 作用 |
lock | 阻止其他线程。如果已被其他线程阻止,则等待到被解除,再获取所有权并阻止。 |
unlock | 立即解除阻止。 |
try_lock | 尝试获取所有权,如果没有被其他线程阻止,则获取所有权并阻止。如果已被其他线程阻止,则返回false。 |
native_handle | 返回 mutex 的句柄。 |
以下演示了一个对 std::mutex 最简单的使用:
void SortVectorMutex(std::mutex &m, vector<int> &vec) { m.lock(); std::sort(vec.begin(), vec.end()); m.unlock(); return; } void PushVectorGuard(std::mutex &m, vector<int> &vec) { m.lock(); vec.push_back(15); vec.push_back(12); vec.push_back(10); m.unlock(); return; } int main() { std::mutex m; vector<int> vec1{2, 1, 4, 8, 7, 5, 9, 3}; std::thread t1(SortVectorMutex, ref(m), ref(vec1)); std::thread t2(PushVectorGuard, ref(m), ref(vec1)); t1.join(); t2.join(); for (auto& i : vec1) { cout << i << endl; } return EXIT_SUCCESS; }
可以看到,std::mutex 的操作仅仅对作用域的手动上锁与解锁。因此,也要牢记,lock 与 unlock 应成对使用避免造成死锁!
那么 std::timed_mutex 呢?无非就是在 mutex 的基础上增加了时间限制功能而已:
名称 | 作用 |
try_lock_for | 等待到时间间隔 |
try_lock_until | 等待到指定时间 |
演示代码:
void SortVectorTimeMutex(std::timed_mutex& m, vector<int>& vec) { std::chrono::milliseconds times = std::chrono::milliseconds(100); // 100毫秒 if (m.try_lock_for(times)){ std::sort(vec.begin(), vec.end()); m.unlock(); } return; }
std::recursive_mutex 与 std::recursive_timed_mutex
std::mutex 及其变种不允许同一个线程对互斥量多次上锁,而 std::recursive_mutex 则允许。相应的 lock 次数也必须和 unlock 次数相等,否则仍然死锁。
例子:
class BrainBox{ public: std::recursive_mutex rec_mutex; public: void PrintHelloByte() { this->rec_mutex.lock(); cout << "Hello Byte" << endl; this->rec_mutex.unlock(); return; } void PrintHelloBlu() { this->rec_mutex.lock(); this->PrintHelloByte(); // 对互斥量重复上锁 cout << "Hello Blu" << endl; this->rec_mutex.unlock(); return; } }; int main() { BrainBox box; std::thread t1(&BrainBox::PrintHelloByte, &box); std::thread t2(&BrainBox::PrintHelloBlu, &box); t1.join(); t2.join(); return EXIT_SUCCESS; }
std::lock_guard 与 std::unique_lock
上面演示了C++标准库4种互斥量的原始用法。在实际开发当中,互斥量更多的是与 std::lock_guard 和 std::unique_lock 相配合使用。
是一种更为智能、安全、现代的用法。std::lock_guard 和 std::unique_lock 设计上并存,并非功能上的替代关系。
std::lock_guard
首先,它是一个模板类,它的语法是:
std::lock_guard<std::mutex> locker(Mutex);
它需要一个互斥量对其进行初始化操作,它的特点是:根据 RAII 原则,在构造函数中上锁(创建即上锁),在析构函数中解锁(销毁即解锁)。
void PushVectorGuard(std::mutex &m, vector<int> &vec) { try { std::lock_guard<std::mutex> locker(m); vec.push_back(15); vec.push_back(12); vec.push_back(10); } catch (const std::exception& e) { cout << e.what() << endl; } return; }
于是,可以总结出它的特点:
1,简单易用。
2,锁定范围是它初始化位置向后的作用域。
3,无法手动上锁、解锁。
4,不能被复制。
5,异常安全,防止线程意外结束导致死锁。
6,不会对 std::mutex 本身进行托管,初始化时请确保 std::mutex 也已经正确初始化。
因此,在需要对资源进行保护的小范围作用域内,应首先考虑使用std::lock_guard。
std::unique_lock
std::lock_guard 有一个显著的问题,在简单且小范围的作用域内,它无疑是高效的。
但是,std::lock_guard 初始化即上锁,涵盖它初始化位置向后的所有作用域。---它并不灵活。
无法手动管理锁定及解锁时机,所以这时候就需要 std::unique_lock 登场了,它同样是一个模板类,拥有和 std::lock_guard 一样的异常安全优点。
首先需要注意的是 std::unique_lock 会获得 mutex 对象的所有权。
一个已经托管给 std::unique_lock 的 mutex 对象就不要再去手动调用方法、给 guard 使用、托管给其他 unique。
语法:
【伪代码】 std::unique_lock<MutexType> name(MutexObj, args); 【一般情况】 std::mutex mutex; std::unique_lock<std::mutex> unique_m(mutex); or std::unique_lock<std::mutex> unique_m(mutex, std::defer_lock); or std::unique_lock<std::mutex> unique_m(mutex, std::adopt_lock);
是的,它有第二个参数,第二个参数是固定的几个值,分别代表:
名称 | 作用 |
std::defer_lock | 默认不锁定互斥量。(不获得所有权) |
std::adopt_lock | 告诉正在初始化的 unique_lock 互斥量已锁定。 |
std::try_to_lock | 默认尝试锁定,如果失败则不阻止当前线程。 |
如果第二个参数为空,那么 std::unique_lock 会默认对托管的互斥量进行 lock 操作,如果互斥量已经 lock,它会等待互斥量被 unlock 后再进行托管并上锁。
std::unique_lock 作为互斥量的强大补充,它拥有以下方法:
名称 | 作用 |
lock | 阻止其他线程。如果已被其他线程阻止,则等待到被解除,再获取所有权并阻止。 |
unlock | 立即解除阻止。 |
mutex | 返回当前托管的互斥量指针。 |
owns_lock | 检查当前 unique_lock 是否与拥有关联互斥量的所有权。 |
release | 解除与互斥量对象的关联。(但不解锁互斥量) |
swap | 与另一个 unique_lock 交换 mutex 所有权。 |
try_lock | 尝试获取所有权,如果没有被其他线程阻止,则获取所有权并阻止。如果已被其他线程阻止,则返回false。 |
try_lock_for | 等待到时间间隔。 |
try_lock_until | 等待到指定时间。 |
代码例子:(参考了 CPP Reference 当中例子)
class BrainBox{ public: std::mutex c_mutex; int value = 3; }; void ChangeValue(BrainBox &skylake, BrainBox &coffeelake) { std::unique_lock<std::mutex> locker1(skylake.c_mutex, std::defer_lock); std::unique_lock<std::mutex> locker2(coffeelake.c_mutex, std::defer_lock); std::lock(locker1, locker2); skylake.value += 1; coffeelake.value += 2; return; }; int main() { BrainBox boxA; BrainBox boxB; std::thread t1(ChangeValue, std::ref(boxA), std::ref(boxB)); std::thread t2(ChangeValue, std::ref(boxB), std::ref(boxA)); t1.join(); t2.join(); cout << "BrainBox A : " << boxA.value << endl; // out 3 cout << "BrainBox B : " << boxB.value << endl; // out 3 return EXIT_SUCCESS; }
于是,可以总结出它的特点:
1,std::unique_lock 是通用互斥包装器,允许延迟锁定、锁定的有时限尝试、递归锁定、所有权转移和与条件变量一同使用。
2,std::unique_lock 可移动,但不可复制。
3,std::unique_lock 会获得互斥量的所有权以完全托管 mutex。
4,std::unique_lock 的资源开销大于 std::lock_guard。
5,默认情况下,std::unique_lock 和 std::lock_guard 一样同样锁定它初始化位置向后的作用域。
std::condition_variable
std::condition_variable 和 std::condition_variable_any 是标准库线程同步的条件变量实现方式,它能根据设定的条件阻塞一个或多个线程。
头文件:
#include <condition_variable>
其中 std::condition_variable 仅支持 std::unique_lock<std::mutex> 类型作为互斥量。
std::condition_variable_any 可以支持任意基本可锁定(BasicLockable)类型作为互斥量,例如 [C++14]std::shared_lock,但也就意味着它的效率低于 std::condition_variable。
它们两个的公共函数接口也基本一致:
名称 | 作用 | 支持 |
wait | 阻止。 | CV and Any |
wait_for | 阻止到时间间隔。 | CV and Any |
wait_until | 阻止到指定时间。 | CV and Any |
notify_one | 放行一个线程,如果此时托管了多个线程,则随机抽取。 | CV and Any |
notify_all | 放行所有线程。 | CV and Any |
native_handle | 返回原生句柄。 | CV |
它的语法是:
【伪代码】
// 负责同步阻塞的互斥量 std::mutex cv_mutex;
// 声明 std::condition_variable cv; // 等待 收到通知放行 cv.wait(cv_mutex); // 条件等待 收到通知且满足条件情况下放行 cv.wait(uni_m, [=] { return !flag; }); // 通知一个线程 cv.notify_one(); // 通知所有线程 cv.cv.notify_all();
例子演示了 std::condition_variable 的使用:
#include <iostream> using std::cout; using std::endl; #include <thread> #include <mutex> #include <condition_variable> void PrintID_CV(std::mutex& mu, std::condition_variable& cv, const int& flag) { std::unique_lock<std::mutex> uni_m(mu); cv.wait(uni_m, [=] { return flag != 1; }); cout << "Thread ID: " << std::this_thread::get_id() << endl; return; } int main() { std::mutex cv_mutex; std::condition_variable cv; int flag = 0; std::thread t1(&PrintID_CV, ref(cv_mutex), ref(cv), flag); while (flag != 1) { std::unique_lock<std::mutex> uni_m(cv_mutex); std::cout << "Input 1 is print thread id." << endl; std::cin >> flag; } cout << "Msg Thread..." << endl; cv.notify_one(); t1.join(); return EXIT_SUCCESS; }
以上例子运行后需要手动在键盘上输入 1 才会打印线程ID。
注意事项:
1,std::condition_variable 和 std::condition_variable_any 对象本身均不可拷贝和赋值。
2,根据代码演示,使用 std::std::condition_variable 与 std::condition_variable_any 都仅仅是负责条件变量,而加锁、解锁操作都始终需要有一个互斥量交由其托管。其中 td::std::condition_variable 仅支持 std::unique_lock<std::mutex>。
3,调用 wait()、wait_for()、wait_until() 函数后,内部会阻止当前线程运行,并 unlock 互斥量。
4,wait()、wait_for()、wait_until() 函数的第二个可选参数为返回 true 或 false 的任何表达式(lambda、Callback),为阻塞条件,当收到解锁信号且阻塞条件不满足(即表达式返回值为False)的情况下才会放行。
5,condition_variable.h 提供了额外的辅助函数 std::notify_all_at_thread_exit,语法为:
void std::notify_all_at_thread_exit (condition_variable& cv, unique_lock<mutex> mutex);
当调用该函数的线程退出后,会通知其他受该 std::condition_variable 托管的线程放行。为了避免误操作,请尽量避免使用该函数或在 wait 函数当中增加第二参数作为条件。
额外补充
std::call_once
使用例子另见:【Example】C++ 单例模式 演示代码 (被动模式、兼容VS2022编译)
std::lock 与 std::try_lock
std::lock
锁定给定的可锁定 (Lockable) 对象
lock1
、lock2
、...
、lockn
,用免死锁算法避免死锁。以对
lock
、try_lock
和unlock
的未指定系列调用锁定对象。若调用lock
或unlock
导致异常,则在重抛前对任何已锁的对象调用unlock
。
std::try_lock
尝试锁定每个给定的可锁定 (Lockable) 对象
lock1
、lock2
、...
、lockn
,通过以从头开始的顺序调用try_lock
。若调用
try_lock
失败,则不再进一步调用try_lock
,并对任何已锁对象调用unlock
,返回锁定失败对象的0
底下标。若调用
try_lock
抛出异常,则在重抛前对任何已锁对象调用unlock
。
--- CPP Reference
void ChangeValueAdopt(BrainBox& skylake, BrainBox& coffeelake) { std::lock(skylake.c_mutex, coffeelake.c_mutex); std::unique_lock<std::mutex> locker1(skylake.c_mutex, std::adopt_lock); std::unique_lock<std::mutex> locker2(coffeelake.c_mutex, std::adopt_lock); skylake.value += 1; coffeelake.value += 2; return; };
结束。2022-03-14 凌晨 3:40。