当前位置:网站首页>Item 37: Make std::threads unjoinable on all paths.
Item 37: Make std::threads unjoinable on all paths.
2022-08-09 14:03:00 【loongknown】
Item 37: Make std::threads unjoinable on all paths.
每个 std::thread 只会处于两种状态状态之一:其一为 joinable,其二为 unjoinable 。一个 joinable 的 std::thread 对应于一个正在或可能在运行的底层线程。例如,一个对应于处于阻塞或者等待调度的底层线程的 std::thread 是 joinable。对应于底层线程的 std::thread 已经执行完成也可以被认为是 joinable。
而 unjoinable 的线程包括:
- 默认构造的
std::thread。这样的std::thread没有执行函数,也就不会对应一个底层的执行线程。 std::thread对象已经被 move。其底层线程已经被绑定到其它std::thread。std::thread已经join。已经join的对应std::thread的底层线程已经运行结束。std::thread已经detach。已经detach的std::thread与其对应的底层线程已经没有关系了。
std::thread 的 joinabilty 状态之所以重要的原因之一是:一个 joinable 状态的 std::thread 对象的析构函数的调用会导致正在运行程序停止运行。例如,我们有一个 doWork 函数,它接收一个过滤函数 filter 和一个最大值 MaxVal 作为参数。 doWork 检查并确定所有条件满足时,对 0 到 MaxVal 执行 filter。对于这样的场景,一般会选择基于任务的方式来实现,但是由于需要使用线程的 handle 设置任务的优先级,只能使用基于线程的方法来实现(相关讨论可以参见 Item 35: Prefer task-based programming to thread-based.)。可能的实现如下:
constexpr auto tenMillion = 10000000; // see Item 15 for constexpr
bool doWork(std::function<bool(int)> filter, // returns whether
int maxVal = tenMillion) // computation was
{
// performed; see
// Item 2 for
// std::function
std::vector<int> goodVals; // values that
// satisfy filter
std::thread t([&filter, maxVal, &goodVals] // populate
{
// 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
对于上面的实现,如果 conditionsAreSatisfied() 返回 true,没有问题。如果 conditionsAreSatisfied() 返回 false 或抛出异常,std::thread 对象处于 joinable 状态,并且其析构函数将被调用,会导致执行程序停止运行。
你可能会疑惑为什么 std::thread 的析构函数会有这样的行为,那是因为其他两种选项可能更加糟糕:
- 隐式的
join。析构函数调用时,隐式去调用join等待线程结束。这听起来似乎很合理,但会导致性能异常,并且这有点反直觉,因为conditionsAreSatisfied()返回false时,也即条件不满足时,还在等待filter计算完成。 - 隐式
detach。析构函数调用时,隐式调用detach分离线程。doWork可以快速返回,但可能导致 bug。因为doWork结束后,其内部的goodVals会被释放,但线程还在运行,并且访问goodVals,将导致程序崩溃。
由于 joinable 的线程会导致严重的后果,因此标准委员会决定禁止这样的事情发生(通过让程序停止运行的方式)。这就需要程序员确保 std::thread 对象在离开其定义的作用域的所有路径上都是 unjoinable 。但是想要覆盖所有的路径并非易事,return、continue、goto、break 或者异常等都能跳出作用域。
无论何时,想在出作用域的路径上执行某个动作,常用的方法是将这个动作放入到一个局部对象的析构函数中。这种对象被成为 RAII(Resource Acquisition Is Initialization)对象,产生这个对象的类是 RAII 类。RAII 类在标准库中很常见,例如 STL 容器(每个容器的析构函数销毁容器中的内容并释放它的内存)中的智能指针(std::unique_ptr 析构函数调用它的 deleter 删除它指向的对象,std::shared_ptr 和 std::weak_ptr 的析构函数中会减少引用计数)、std::fstream 对象(析构函数关闭相应的文件)。但是 std::thread 对象没有标准的 RAII 类,这可能是标准委员会拒绝将 join 和 detach 作为默认选项,因为他们也不知道这个类应该有什么样的行为。
好在实现这样的一个类也并非难事。例如,你可以让用户指定 ThreadRAII 类在销毁时选择 join 还是 detach:
class ThreadRAII {
public:
enum class DtorAction {
join, detach }; // see Item 10 for
// enum class info
ThreadRAII(std::thread&& t, DtorAction a) // in dtor, take
: action(a), t(std::move(t)) {
} // action a on t
~ThreadRAII()
{
if (t.joinable()) {
// see below for
// joinability test
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
}
std::thread& get() {
return t; } // see below
private:
DtorAction action;
std::thread t;
};
关于上面代码的几点说明:
- 构造函数只接收
std::thread的右值,因为std::thread不可拷贝。 - 构造函数参数排列顺序符合调用者的直觉(
std:thread为第一个参数,DtorAction为第二个参数),但是成员变量的初始化符合成员变量的申明顺序。在这个类中两个成员变量的前后顺序没有意义,但是通常而言,一个成员的初始化依赖另一个成员。 ThreadRAII提供了get函数,用于访问底层的std::thread对象。提供get方法访问std::thread,避免了重复实现所有std::thread的接口。ThreadRAII的析构函数首先检查t是否为joinable是必要的,因为对一个unjoinable的线程调用join和detach将产生未定义的行为。
将 ThreadRAII 应用于 doWork 的例子上:
bool doWork(std::function<bool(int)> filter,
int maxVal = tenMillion)
{
std::vector<int> goodVals;
ThreadRAII t( // use RAII object
std::thread([&filter, maxVal, &goodVals]
{
for (auto i = 0; i <= maxVal; ++i)
{
if (filter(i)) goodVals.push_back(i); }
}),
ThreadRAII::DtorAction::join // RAII action
);
auto nh = t.get().native_handle();
...
if (conditionsAreSatisfied()) {
t.get().join();
performComputation(goodVals);
return true;
}
return false;
}
这个例子中,我们选择 join 作为 ThreadRAII 析构函数的动作。正如前文所述,detach 可能导致程序崩溃,join 可能导致性能异常。两害取其轻,性能异常相对可以接受。
正如 Item 17: Understand special member function generation. 所介绍的,由于 ThreadRAII 自定义了析构函数,编译器将不在自动生成移动操作,但没有理由让 ThreadRAII 对象不支持移动。因而,需要我们将移动操作标记为 default:
class ThreadRAII {
public:
enum class DtorAction {
join, detach };
ThreadRAII(std::thread&& t, DtorAction a)
: action(a), t(std::move(t)) {
}
~ThreadRAII()
{
... // as before
}
ThreadRAII(ThreadRAII&&) = default; // support
ThreadRAII& operator=(ThreadRAII&&) = default; // moving
std::thread& get() {
return t; } // as before
private:
DtorAction action;
std::thread t;
};
至此,本文结束。
参考:
边栏推荐
猜你喜欢
随机推荐
RHCE课程总结
How to adjust the spacing between numbers and text in Word?
* 2-2 OJ 1163 missile interception of beta
RHCE Course Summary
*1-2 OJ 190 游程编码
CTF题解五 Web PHP大法(实验吧)
网站小程序开发有哪些步骤?
*2-1 OJ 254 Flip Pancakes
[manjaro] updated kernel file loading failure
不要小看一个Redis!从头到尾全是精华,阿里Redis速成笔记太香了
在Word中如何调整编号和文字之间的间距?
What is the cost of small program development and production?Three development methods cost analysis!
Assembly Language Learning (6) Curriculum Design 1
Recursive implementation of the Tower of Hanoi problem
How does the JVM judge that an object is useless
*3-1 CCF 2014-09-1 相邻数对
[MRCTF2020]套娃-1
*2-1 OJ 254 翻煎饼
shell课程总结
IK学习笔记(1)——CCD IK









