c++ 多线程编程std::thread, std::shared_mutex, std::unique_lock
在C++11新标准中,可以简单通过使用thread库,来管理多线程,使用时需要#include <thread>
头文件。
简单用例如下:
1 std::thread(Simple_func); 2 std::thread t(Simple_func); 3 t.detach();
第一行是直接启动一个新线程来执行Simple_func函数,而第二行先声明一个线程函数t(返回类型为thread),然后用detach方法等待线程结束。
C++11有两种方式来等待线程结束:
- detach方式,启动的线程自主在后台运行,当前的代码继续往下执行,不等待新线程结束。前面代码所使用的就是这种方式。
- 调用detach表示thread对象和其表示的线程完全分离;
- 分离之后的线程是不在受约束和管制,会单独执行,直到执行完毕释放资源,可以看做是一个daemon线程;
- 分离之后thread对象不再表示任何线程;
- 分离之后joinable() == false,即使还在执行;
- join方式,等待启动的线程完成,才会继续往下执行。假如前面的代码使用这种方式,其输出就会0,1,2,3,因为每次都是前一个线程输出完成了才会进行下一个循环,启动下一个新线程。
- 只有处于活动状态线程才能调用join,可以通过joinable()函数检查;
- joinable() == true表示当前线程是活动线程,才可以调用join函数;(非joinable线程直接调用join会触发abort函数中断)
- 默认构造函数创建的对象是joinable() == false;
- join只能被调用一次,之后joinable就会变为false,表示线程执行完毕;
- 调用 ternimate()的线程必须是 joinable() == false;
- 如果线程不调用join()函数,即使执行完毕也是一个活动线程,即joinable() == true,依然可以调用join()函数;
每创建一个合法的thread对象,变对应一个底层的线程,对一个thread对象来说,其存在两个状态:可结合和不可结合
线程的可结合性(joinability)是指线程是否可以被其他线程等待并回收其资源。在具备可结合性的线程中,创建线程的线程可以调用特定的函数(如pthread_join
)等待该线程的结束,并在线程终止后回收其资源。如上例:在t被声明后,该线程立即执行,而.join()和.detach()方法是主线程等待线程结束的方法,若不调用这两种方法,则会在主线程退出时抛出异常"abort() has been called",这是因为t是可结合的,在主线程结束调用t的析构函数时会抛出此异常。你可能会问为什么可结合的线程不被声明等待方法就无法析构,此处使用EffectiveModernCpp中的例子解释:
constexpr auto tenMillion = 10000000; //constexpr见条款15 bool doWork(std::function<bool(int)> filter, //返回计算是否执行; int maxVal = tenMillion) //std::function见条款2 { std::vector<int> goodVals; //满足filter的值 std::thread t([&filter, maxVal, &goodVals] //填充goodVals { for (auto i = 0; i <= maxVal; ++i) { if (filter(i)) goodVals.push_back(i); } }); auto nh = t.native_handle(); //使用t的原生句柄 … //来设置t的优先级 if (conditionsAreSatisfied()) { t.join(); //等t完成 performComputation(goodVals); return true; //执行了计算 } return false; //未执行计算 }
可看到上例中给出了一个doWork函数,其目的是使用过滤函数filter得到合法的数据goodVals,并在conditionsAreSatisfied()条件满足下处理数据,为了提高效率使用了多线程来处理得到goodVals。
此处有个问题,即conditionsAreSatisfied()为true时不会产生异常,若conditionsAreSatisfied()为false时则会抛出异常,因为t未被声明为join或detach。假设t可以被隐式声明为join或detach,那么会产生如下两种情况:
-
隐式
join
。这种情况下,std::thread
的析构函数将等待其底层的异步执行线程完成。这听起来是合理的,但是可能会导致难以追踪的异常表现。比如,如果conditonAreStatisfied()
已经返回了false
,doWork
继续等待过滤器应用于所有值就很违反直觉。 -
隐式
detach
。这种情况下,std::thread
析构函数会分离std::thread
与其底层的线程。底层线程继续运行。听起来比join
的方式好,但是可能导致更严重的调试问题。比如,在doWork
中,goodVals
是通过引用捕获的局部变量。它也被lambda修改(通过调用push_back
)。假定,lambda异步执行时,conditionsAreSatisfied()
返回false
。这时,doWork
返回,同时局部变量(包括goodVals
)被销毁。栈被弹出,并在doWork
的调用点继续执行线程。
所以说对于std::thread对象必须显式声明其等待方法。
相对地,不具备可结合性的线程被称为“分离线程”。分离线程在终止时,会自动释放其资源,无需其他线程进行回收操作。分离线程的创建和终止过程更加轻量级,不需要等待或回收线程的资源,适用于那些不需要线程结果或资源清理的场景。
不可结合状态有以下四种:
- 默认构造的
std::thread
s。这种std::thread
没有函数执行,因此没有对应到底层执行线程上。 - 已经被移动走的
std::thread
对象。移动的结果就是一个std::thread
原来对应的执行线程现在对应于另一个std::thread
。 - 已经被
join
的std::thread
。在join
之后,std::thread
不再对应于已经运行完了的执行线程。 - 已经被
detach
的std::thread
。detach
断开了std::thread
对象与执行线程之间的连接。
向线程传递参数
向线程调用的函数传递参数也是很简单的,只需要在构造thread
的实例时,依次传入即可。例如
int Simple_func(int a, int b); std::thread t(Simple_func,1,2);
需要注意的是,默认的会将传递的参数以拷贝的方式复制到线程空间,即使参数的类型是引用,如果在线程中使用引用来更新对象时,就需要注意了。默认的是将对象拷贝到线程空间,其引用的是拷贝的线程空间的对象,而不是初始希望改变的对象。例如:
int ChangeNum(int &a); int num=0; std::thread t(ChangeNum,num); t.join();
在线程内,将对象的字段a和b设置为新的值,但是在线程调用结束后,这两个字段的值并不会改变。这样由于引用的实际上是局部变量num的一个拷贝。
若想通过线程改变对象值,需调用std::ref
,将num
的引用传入线程,如:
std::thread t(ChangeNum,std::ref(num));
thread
是可移动的(movable)的,但不可复制(copyable)。可以通过move
来改变线程的所有权,灵活的决定线程在什么时候join或者detach。
std::thread也可以去包装一个类,前提是该类对()操作符进行了重载,使其相当于拥有了函数的性质。(此处类似于std::bind的绑定)
在多线程编程的时候,资源竞争是很常见的问题,因此需要引入互斥锁。c++11中提供了std::mutex,而在C++17开始,标准库提供了shared_mutex类。
需注意,std::mutex
既不可移动,也不可复制。因而包含他们的类也同时是不可移动和不可复制的。
对于shared_mutex,可以理解为共享锁,允许多个线程同时对同一资源进行读操作。而lock_guard、unique_lock可以理解为独占锁,只允许一个线程对资源进行写操作。
在一些只读函数中可以用std::shared_mutex,而在写操作函数中需用std::unique_lock。
std::shared_mutex是c++17中引入的,不支持std::mutex,需用std::shared_mutex声明互斥信号量。
- std::lock_guard (c++11): 单个std::mutex(或std::shared_mutex)
- std::unique_lock (c++11): 单个std::mutex(或std::shared_mutex), 用法比std::lock_guard更灵活
- std::shared_lock (c++14): 单个std::shared_mutex
- std::scoped_lock (c++17): 多个std::mutex(或std::shared_mutex)
以上四种锁均是c++中符合Scoped Locking(将RAII手法应用于locking的并发编程技巧,即在构造时获得锁,在析构时释放锁)。
其中c++17提供了std::scoped_lock可以对多个不同类型的mutex进行Scoped Locking,其预防死锁策略很简单,假设要对n个mutex(mutex1, mutex2, ..., mutexn)上锁,那么每次只尝试对一个mutex上锁,只要上锁失败就立即释放获得的所有锁(方便让其他线程获得锁),然后重新开始上锁,处于一个循环当中,直到对n个mutex都上锁成功。这种策略是基本上是有效的。
参考文章:https://immortalqx.github.io/2021/12/04/cpp-notes-3/
https://zhuanlan.zhihu.com/p/461530638