联结性
对于std::thread创建的线程对象,要么join它,要么detach它,否则会导致程序崩溃。
要想从理论上解释上述原因,就要了解一下thread的可联结性。
std::thread对象都是处于两种状态之一:可联结的和不可联结的。
可联结状态:
- std::thread对应底层以异步方式已运行或可运行的线程
- 对应的底层线程处于阻塞或等待调度
- 对应的底层线程已运行至结束
不可联结状态:
- 默认构造的std::thread:它没有可以执行的函数,因此没有对应的底层执行线程
- 已移动的std::thread:移动的结果是,一个std::thread所对应的底层执行线程被对应到另外一个std::thread
- 已联结的thread: 联结后,thread对象不再对应到已结束运行的底层执行线程
- 已分离的thread: 分离操作会把thread对象和它对应的底层执行线程之间的连接断开
thread的可联结性极其重要的原因之一是,如果可联结的线程对象的析构函数被调用,则程序的执行就终止。
假设thread的析构函数不终止程序运行
如果不终止程序运行,那么它要么隐式join,要么隐式detach。
考虑下面的程序:
// 注意,这是一个有缺陷的程序示例
constexpr atuo tenMillion = 10000000;
// 执行任务如下:
// 1. 接受一个筛选器函数filter和最大值作为形参
// 2. 校验做计算的条件全部成立后,对筛选器选出的0-maxVal之间的数值实施计算
// 假设筛选和条件检验都是耗时操作,并发处理它们
bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion)
{
std::vector<int> goolVals;
std::thread t([&filter, maxVal, &goodVals] {
for (auto i = 0; i <= maxVal; ++i) {
if (filter(i))
goodVals.push_back(i);
}
});
auto nh = t.nativate_handle(); // 设置线程优先级,线程t已经启动,再设置优先级无效!
if (conditionAreSatisfied()) {
t.join();
performComputation(goodVals);
return true;
}
return false;
}
可以明显看出程序问题:如果conditionAreSatisfied返回true,不会有问题,如果返回false,t的析构函数会被调用,因为t还是可联结状态,程序会终止。
假设不终止程序,有以下两种可能:
-
隐式join
- 这种情况下,thread的析构函数会等待底层异步执行线程完成
- 这可能导致难以追踪的性能异常
- 如果conditionAreSatisfied早已返回了false,doWork却不立即返回,而是等待所有值上的遍历筛选,这是违反直觉的
-
隐式detach
- thread析构函数会分离对象与底层执行线程之间的连接,而该底层线程会继续执行
- 这会导致更致命的调试问题
- doWork内goodVals是通过引用捕获的局部变量,假如lambda式以异步方式运行,conditionAreSatisfied返回了false,则doWork也直接返回,它的局部变量(包括goodVals)销毁,doWork的栈帧被弹出,但线程却仍在doWork的调用方继续运行
- 在doWork的调用方后,某个时刻,会调用其他函数,至少会有一个函数可能使用部分doWork的栈帧使用过的内存,假如f。当f运行时,doWork发起的lambda仍然在异步执行
- 该lambda对goodVals的操作,已经是在操作f了!从f的视角看,wtf,内存被莫名的修改了!
所以,销毁一个可联结的线程实在是太恐怖了,这么看,可联结的线程的析构函数导致程序终止可能是比较合理的方式了。
使用RAII破解
但这样一来,这个黑锅就甩给了用户,你要使用std::thread对象,就得确保从它定义的作用域出去的任何路径,使它成为不可联结的状态。
任何时候,只要想在每条出向路径上都执行某动作,最常用的方法就是在局部对象的析构函数中执行该动作。这样的对象就是RAII对象(Resource Acquisition Is Initialization)。
RAII类在标准库中很常见,如STL容器(析构函数会析构容器内容并释放内存)、智能指针(析构函数调用删除器或引用计数自减)、std::fstream(析构函数会关闭对应文件)等。
但标准未提供thread的RAII实现,不过可以自己写一个:
class ThreadRAII
{
public:
enum class DtorAction {join, detach};
ThreadRAII(std::thread&& t, DtorAction a): action(a), t(std::move(t)) {} // 先初始化析构动作,再初始化线程对象。线程对象参数是右值
~ThreadRAII()
{
if (t.joinable()) { // 先判断是否可连接,对一个不可联结的对象执行以下操作会导致未定义的行为
if (action == DtorAction::join) {
t.join;
} else {
t.detach();
}
}
}
ThreadRAII(ThreadRAII&&) = default;
ThreadRAII& operator=(ThreadRAII&&) = default;
std::thread& get() // get接口与智能指针类似,有了这个接口,该类型的对象可用于需要直接使用std::thread类型对象的语境
{
return t;
}
private:
DtorAction action;
std::thread t;
}
小结
使用thread时,一定要注意让线程变得不可联结,否则程序会崩溃。
本文提供了一种思路,可以更好地对thread作出封装,从而保证程序的健壮性。
除了thread对象,当然也可以有其他选择,如async,boost::thread_group等。
参考资料
《Effective Modern C++》