设为首页 加入收藏

TOP

Effective Modern C++ 条款37 在所有路径上,让std::thread对象变得不可连接(unjoinable)(一)
2016-09-15 20:03:15 】 浏览:806
Tags:Effective Modern 条款 所有 路径 std::thread 对象 变得 不可 连接 unjoinable

让std::thread对象在所有路径都无法连接

每个std::thread对象的状态都是这两种中的一种:joinable(可连接的)或unjoinable(不可连接的)。一个可连接的std::thread对应一个底层异步执行线程,例如,一个std::thread对应的一个底层线程,它会被阻塞或等待被调度,那么这个std::thread就是可连接的。std::thread对象对应的底层线程可以将代码运行至结束,也可将其视为可连接的。

不可连接的std::thread的意思就如你想象那样:std::thread不是可连接的。不可连接的std::thread对象包括:

默认构造的 std::thread。这种 std::thread没有函数可以执行,因此没有对应的底层执行线程。 被移动过的 std::thread。移动的结果是,一个 std::thread对应的底层执行线程被对应到另一个 std::thread。 被连接过(调用了join)的 std::thread。在调用了 join之后, std::thread对应的底层执行线程结束运行,就没有对应的底层线程了。 被分离(detach)的 std::threaddetachstd::thread对象与它对应的底层执行线程分离开。

std::thread的连接性是很重要的,其中一个原因是:如果一个可连接的线程对象执行了析构操作,那么程序会被终止。例如,假设我们有一个函数doWork,它的参数包含过滤器函数filter、一个最大值maxVal。doWork把0到maxVal之间值传给过滤器,然后满足特定条件就对满足过滤器的值进行计算。如果执行过滤器函数是费时的,而检查条件也是费时的,那么并发做这两件事是合理的。

我们其实会更偏向于使用基于任务的设计(看条款35),但是让我们假定我们想要设置执行过滤器线程的优先级。条款35解释过请求使用线程的本机句柄(native handle)时,只能通过std::thread的API;基于任务的API没有提供这个功能。因此我们的方法是基于线程,而不是基于任务。

我们可以提出这样的代码:

constexpr auto tenMillion = 10000000; // 关于constexpr,看条款15 bool doWork(std::function
     
       filter, // 返回是否会进行计算 int maxVal = tenMillion) // 关于std::function,看条款2 { std::vector
      
        goodVals; // 满足过滤器的值 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(); // 获取t的本机句柄 ... // 使用t的本机句柄设置t的优先级 if (conditionsAreSatisfied()) { t.join(); // 等待t结束 performComputation(goodVals); return true; // 会进行计算 } return false; // 不会进行计算 }
      
     

在我解释这个代码为什么有问题之前,我想提一下tenMillion的初始值在C++14可以变得更有可读性,利用C++14的能力,把单引号作为数字的分隔符:

constexpr auto tenMillion = 10'000'000; // C++14

我还想提一下在线程t开始执行之后才去设置它的优先级,这有点像众所周知的马脱缰跑了后你才关上门。一个更好设计是以暂停状态启动线程t(因此可以在执行之前修改它的优先级),但我不想那部分的代码使你分心。如果你已经严重分心了,那么请去看条款39,因为那里展示了如何启动暂停的线程。

回到doWork,如果conditionsAreSatisfied()返回true,那么没问题,但如果返回false或者抛出异常,那么在doWork的末尾,调用std::thread的析构函数时,它状态是可连接的,那会导致执行中的程序被终止。

你可能想知道std::thread的析构函数为什么会表现出这种行为,那是因为另外两种明显的选项会更糟。它们是:

隐式连接(join)。在这种情况下,std::thread的析构函数会等待底层异步执行线程完成工作。这听起来合情合理,但是这会导致难以追踪的性能异常。例如,如果conditionAreSatisfied()已经返回false了,doWork函数还要等待过滤器函数的那个循环,这是违反直觉的。 隐式分离(detach)。在这种情况下,std::thread的析构函数会分离std::thread对象与底层执行线程之间的连接,而那个底层执行线程会继续执行。这听起来和join那个方法一样合理,但它导致更难调试的问题。例如,在doWork中,goodVals是个通过引用捕获的局部变量,它可以在lambda内被修改(通过push_back),然后,假如当lambda异步执行时,conditionsAreSatisfied()返回false。那种情况下,doWork会直接返回,它的局部变量(包括goodVals)会被销毁,doWork的栈帧会被弹出,但是线程仍然执行。

在接着doWork调用端之后的代码中,某个时刻,会调用其它函数,而至少一个函数可能会使用一部分或者全部doWork栈帧占据过的内存,我们先把这个函数称为f。当f运行时,doWork发起的lambda依然会异步执行。lambda在栈上对goodVals调用push_back,不过如今是在f的栈帧中。这样的调用会修改过去属于goodVals的内存,而那意味着从f的角度看,栈帧上的内存内容会自己改变!想想看你调试这个问题时会有多滑稽。

标准委员会任务销毁一个可连接的线程实在太恐怖了,所以从根源上禁止它(通过指定可连接的线程的析构函数会终止程序)。

这就把责任交给了你,如果你使用了一个std::thread对象,你要确保在它定义的作用域外的任何路径,使它变为不可连接。但是覆盖任何路径是很复杂的,它包括关闭流出范围然后借助returncontinuebreakgoto或异常来跳出,这有很多条路径。

任何时候你想要在每一条路径都执行一些动作,那么最常用的方法是在局部对象的析构函数中执行动作。这些对象被称为了RAII对象,而产生它们的类被称为RAII类(RAII(Resource Acquisition Is Initialization)表示“资源获取就是初始化”,即使技术的关键是销毁,而不是初始化)。RAII类在标准库很常见,例子包括STL容器(每个容器的析构函数都会销毁容器的内容并释放内存)、标准智能指针(条款18-20解释了std::unique_ptr析构函数会对它指向的对象调用删除器,而std::shared_ptrstd::weak_ptr的析构函数会减少引用计数)、std::fstream对象(它们的析构函数会关闭对应的文件),而且还有很多。然而,没有关于std::thread的标准RAII类,可能是因为标准委员会拒绝把joindetach作为默认选项,这仅仅是不知道如何实现这样类。

幸运的是,你自己写一个不会很难。例如,下面这个类,允许调用者指定ThreadRAII对象(一个std::thread的RAII对象)销毁时调用join或者detach

 class ThreadRAII { public: enum class DtorAction { join, detach }; // 关于enum class,请看条款10 ThreadRAII(std::thread&& t, DtorA
首页 上一页 1 2 下一页 尾页 1/2/2
】【打印繁体】【投稿】【收藏】 【推荐】【举报】【评论】 【关闭】 【返回顶部
上一篇C++学习笔记(字符串string、vecto.. 下一篇C++学习笔记(五):高级编程:文..

最新文章

热门文章

Hot 文章

Python

C 语言

C++基础

大数据基础

linux编程基础

C/C++面试题目