每个std::thread都有两个状态:joinable和unjoinable。每一个可以join的线程对象都关联一个处于运行或者可以运行状态的底层异步线程。对于关联处于阻塞或者等待调度状态的底层线程的线程对象是可以join的。例如关联的底层线程运行结束了,也是可以join的。
不可join的线程对象如你所期望的,包含:
默认构造的线程对象 没有功能函数,故而没有关联底层可以运行的线程,所以不可join。
被move的线程对象, 底层线程关联到了两外一个std::thread对象上了,所以被move的std::thread不关联底层线程,不可join。
已经被join的线程对象,关联的底层线程已经运行结束,不再关联。
detach的线程,底层线程和线程对象已经分开。
线程对象的join功能很重要,这是因为当其析构函数调用时,程序就终止了。设想我们有一个doWork函数,它有两个参数,一个是过滤函数,另外一个是个整形参数,maxVal表示参数最大值。doWork先确保条件满足,然后计算,计算过程是将0-maxVal之间的数据传递给过滤函数。如果过滤过程很耗时,而且判断条件是否满足也会很耗时,那么就有必要并发的执行这两个过程。
我们的最佳选择是基于任务的设计。但设想我们需要指定线程的优先级,就需要线程API,而基于任务的设计就无法提供,就必须使用基于线程的设计。可以写下面代码:
constexpr auto tenMillion = 10000000;
bool doWork(std::function<bool(int)> filter,
int maxVal = tenMillion)
{
std::vector<int> goodVals;
std::thread t([&filter, maxVal, &goodVals]{
for(int i = 0; i < maxVal; i++) {
if (filter(i)) goodVals.push_back(i);
});
if (conditionsAreSatisfied( )) {
t.join();
performComputation(goodVals);
return true;
}
return false;
}
当条件满足时候,线程对象被join,一切正常结束。而如果条件返回false或者抛出异常,线程对象在析构函数中为joinable,程序会终止。
你可能想知道,为啥std::thread析构函数设计成这样。这是因为另外两个显而易见的选项更拙劣。它们是:
隐式join。这种情况下析构函数会等待关联的底层异步线程执行完毕。这听起来有道理,但会引起效率异常问题,而且难以排查。比如,在conditionsAreSatisfied已经返回false情况下,继续等待对所有值的filter执行结果,就不合理。
隐式detach。这种情况下,线程的析构函数会切断线程对象和底层线程之间的联系。底层线程继续运行。这听起来也不比join的设计方案好多少,却更带来的调试困难问题更为严重。比如,goodVals是一个局部变脸, 通过引用方式捕获。它在lamda中可以被改变(通过push_back). 设想当lamda正异步执行,conditionsAreSatisfied返回了false。这种情况下,doWork应该返回,局部变量goodVals也应该销毁。它的栈帧会弹出,运行的线程却会继续留在doWork的调用点。
调用点以下的语句也许会有函数调用。这可能会访问到doWork的栈帧所在的内存,就可能会导致程序运行结束。我们称呼这个函数f,当f执行的时候,doWork初始化的lamda仍在运行,lamda可能会对goodVals曾经占用的内存调用push_back。因为goodVals曾经占用的内存现在属于f的栈帧。从f角度看,它的栈帧自己改变了!想向你在调试这个问题时多有趣。
因为销毁一个可join的线程对象后果很严重,标准委员会禁止了这一做法。(明确规定可join的线程析构时终止程序)。
这就把std::thread成为不可join的职责转交到你的手上,而且要在每条离开作用域的路径上确保。但覆盖每条路径会很复杂。包含了return,continue,break,goto和异常,这些路径非常多。
每次你想在每条路径执行某个行为时,通常的方案是将这一行为封装在局部对象的析构函数中。这一局部对象被成为RAII对象,类被成为RAII类。(RAII本意指资源在初始化时候获取,关键却是析构函数中释放资源)RAII在标准库中很常见,STL的容器(每个容器都在析构函数中释放资源,销毁内容)。标准的智能指针,std::fstream(析构函数关闭文件)。但对于std::thread却没有标准的RAII,这是因为标准委员会同时否定了join和detach作为默认行为的方案,线程对象就不知道应该怎么做了。
幸运的是,自己写一个也不太困难。比如下面的类就允许用户自己定义ThreadRAII析构时,选择join还是detach。
class ThreadRAII {
public:
enum class DtoAction{join, detach};
ThreadRAII(std::thread &&t, DtoAciton a)
:action(a), t(std::move(t)){
}
~ThreadRAII() {
if () {
if (t.joinable()) {
if (action == DtoAction::join) {
t.join();
} else {
t.detach();
}
}
private:
DtoAction action;
std::thread t;
};
解释一下:
构造函数只接受一个std::thread右值,因为std::thread无法拷贝,只有move。
参数顺序符合用户直觉。(构造在前,析构在后)。在初始化列表中,为了与声明顺序一致,std::thread
放在了最后。在这个例子无影响。但更一般的情况,变量初始化存在依赖关系,std::thread在初始化后,会立即投入运行。因此将std::thread对象放在最后,是一个好的习惯。这保证了所有与之相关的成员变量都初始化了,因此可以安全的执行关联的异步线程。
在洗后函数中,先测试std::thread是否可join。这是必要的,因为对不可join对象做join或者detach操作会出席那未定义行为。