37.令std::thread在所有路径上unjoinable

joinable与unjoinable

 
每个std::thread对象都处于以下两种状态之一:joinable或unjoinable。joinable std::thread对应于正在运行或可能正在运行的底层异步执行线程。例如,对应于阻塞或等待调度的底层线程的std ::thread是joinable,与已完成运行的底层线程相对应的std::thread对象也可被认为是joinable。

显然,不属于上述情况的std::thread自然为unjoinable,一般而言有四种情况:

  1. 默认构造的std::thread
    此类std::thread没有执行函数,因此不对应于任何底层执行线程。
  2. 已被move的std::thread
    move意味着用于对应当前std::thread的底层执行线程现在已对应于别的std::thread。
  3. 已被joined的std::thread
    在joined之后,std::thread对象不再对应于已完成运行的底层执行线程。
  4. 已被detached的std::thread
    detach意味着切断std::thread对象与其对应的底层执行线程之间的连接。

std::thread的joinability之所以重要,是因为如果调用joinable线程的析构函数,则将导致当前执行程序意外终止。


问题实例

 
假设当前存在一个函数doWork,它以过滤器函数filter、最大值maxVal作为参数。doWork检查是否所有计算条件均已符合,然后将计算结果采用过滤器过滤一次。如果进行过滤非常耗时,并且确定是否满足doWork计算条件也很耗时,那么执行并发似乎是一个很好的选择。理论上我们应当采用任务式并发编程(参见Item35),但此项任务需要设定执行filter的线程的优先级,future无法提供这项操作,因此我们只能使用std::thread。实例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
constexpr auto tenMillion = 10000000;
bool doWork(std::function<bool(int)> filter,int maxVal = tenMillion{
std::vector<int> goodVals; // values that satisfy filter
std::thread t([&filter, maxVal, &goodVals]{
for (auto i = 0; i <= maxVal; ++i){
if (filter(i)) goodVals.push_back(i);
}
})
auto nh = t.native_handle(); // use t's native handle to set t's priority
...
if (conditionsAreSatisfied()) {
t.join(); // let t finish
performComputation(goodVals);
return true; // computation was performed
}
return false; // computation was not performed
}

在描述这段代码的问题之前,我们首先声明在C++14中,tenMillion的声明可以更具可读性:
1
constexpr auto tenMillion = 10'000'000; // C++14

此外,我们似乎不应当在线程已经开始运行后设定优先级,但这并不影响本节的讨论,关于如何令线程暂停的讨论在Item39。


问题剖析

 
如果conditionsAreSatisfied()返回true,上述程序一切正常。但如果它返回false或抛出异常,则std::thread对象t在doWork完成时处于joinable状态,这将导致程序意外终止(terminate),因为doWork完成后开始调用t的析构函数。

你可能会认为std::thread析构函数的行为不够合理,但实际上另外两种选择更糟:

  1. 隐式join
    在这种情况下,std::thread的析构函数将等待其底层异步执行线程完成。这听起来很合理,但可能导致难以追查的性能缺陷。举例而言,如果conditionsAreSatisfied()已经返回false,那么doWork等待filiter执行结果是毫无意义的。
  2. 隐式detach
    在这种情况下,std::thread的析构函数将切断std :: thread对象与其底层执行线程之间的连接,底层线程将继续运行。这听起来并不比隐式join合理,但它的破坏性更甚一筹。举例而言,doWork中goodVals是通过引用捕获的局部变量,它会被t中运行的lambda修改(通过push_back)。 那么,假设当lambda异步运行时,conditionsAreSatisfied()返回false,doWork将返回false,并且其内部的局部变量(包括goodVals)将被销毁,因此底层线程上的执行方法将无以为继(确切的说,它将访问以前是goodVals但现在已经已经变成了别的对象的内存),这直接导致了雪崩。

调用一个joinable std::thread的析构函数将引起严重后果,因此标准委员会禁止执行这种操作(一旦执行则触发terminate)。因此作为开发者,我们有责任确保如果使用std::thread对象,它将在所有执行路径保持unjoinable。但是覆盖所有的执行路径并非易事,它包括return、continue、break、goto或异常等能够跳出作用域的情况,这些情况意味着海量的路径。

如果你需要在所有出作用域的地方做某件事,最常见的方法就是将这件事放入到这个局部对象的析构函数中,这项技术被称为RAII(详见Effective C++ Item 13)。std::thread并不是一个RAII类,因为标准委员会拒绝在其析构时执行隐式join或detach,但他们也没想出更好的主意。


RAII thread

 
幸运的是,自己写一个RAII thread并不困难。下述设计将允许调用者指定在销毁ThreadRAII对象时是否应调用join或detach:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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();
}
}
}
std::thread& get() { return t; }
private:
DtorAction action;
std::thread t;
};

以下该类的一些关键性介绍:

  1. 构造函数只接受std::thread rvalue,因为我们想将传入的std::thread移动到ThreadRAII对象中(std::thread move-only)。
  2. ThreadRAII提供了一个get成员函数来获取它对应的std::thread对象,这模仿了智能指针的get方法。提供get方法可以避免ThreadRAII实现所有的std::thread的接口。
  3. ThreadRAII析构函数首先检查了std::thread是否是joinable,这一点十分必要,因为在unjoinable的std::thread对象上调用join或detach会导致未定义的行为。

ThreadRAII的实际使用案例如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool doWork(std::function<bool(int)> filter,int maxVal = tenMillion){
std::vector<int> goodVals;
ThreadRAII t(
std::thread([&filter, maxVal, &goodVals]{
for (auto i = 0; i <= maxVal; ++i){
if (filter(i)) goodVals.push_back(i); }
}),
ThreadRAII::DtorAction::join
);
auto nh = t.get().native_handle();

if (conditionsAreSatisfied()) {
t.get().join();
performComputation(goodVals);
return true;
}
return false;
}

不难看出,我们选择了在ThreadRAII中调用join函数等待线程执行结束,因为上文提到使用detach可能会导致程序crash,难以调试,虽然join可能会导致性能下降,但权衡二者,似乎性能下降还在可接受范围之内。C++11和C++14没有实现线程中断的机制,该机制可以手动实现,但不在本书的讨论范围之内。

Item17中的三五法则解释了编译器不会为ThreadRAII生成移动操作,但ThreadRAII理应支持移动操作,因此我们可以利用default完成对它们的声明与定义:

1
2
3
4
5
6
7
8
9
10
11
12
class ThreadRAII{
public:
enum class DtorAction { join, detach }; // as before
ThreadRAII(std::thread&& t, DtorAction a):action(a), t(std::move(t)) {}
~ThreadRAII(){… // as before}
ThreadRAII(ThreadRAII&&) = default;
ThreadRAII& operator=(ThreadRAII&&) = default;
std::thread& get() { return t; } // as before
private: // as before
DtorAction action;
std::thread t;
};


总结

  1. 确保std::thread在所有路径上unjoinable。
  2. thread析构函数中执行join可能会导致性能下降。
  3. thread析构函数中执行deatch可能会导致程序crash。
  4. 将std::thread声明为data member的最后一个成员(因为它一旦初始化就开始运行)。