目录
条款37:使std::thread型别对象在所有路径皆不可联结
每个std::thread
对象处于两个状态之一:可联结的(joinable)或者不可联结的(unjoinable)。可结合状态的std::thread
对应于正在运行或者可能要运行的异步执行线程。比如,对应于一个阻塞的(blocked)或者等待调度的线程的std::thread
是可结合的,对应于运行结束的线程的std::thread
也可以认为是可结合的。
不可结合的std::thread
正如所期待:一个不是可结合状态的std::thread
。不可结合的std::thread
对象包括:
std::thread
s。这种std::thread
没有函数执行,因此没有对应到底层执行线程上。std::thread
对象。移动的结果就是一个std::thread
原来对应的执行线程现在对应于另一个std::thread
。join
的std::thread
。在join
之后,std::thread
不再对应于已经运行完了的执行线程。detach
的std::thread
。detach
断开了std::thread
对象与执行线程之间的连接。这使你有责任确保使用std::thread
对象时,在所有的路径上超出定义所在的作用域时都是不可结合的。但是覆盖每条路径可能很复杂,可能包括自然执行通过作用域,或者通过return
,continue
,break
,goto
或异常跳出作用域,有太多可能的路径。
每当你想在执行跳至块之外的每条路径执行某种操作,最通用的方式就是将该操作放入局部对象的析构函数中。这些对象称为RAII对象(RAII objects),从RAII类中实例化。(RAII全称为 “Resource Acquisition Is Initialization”(资源获得即初始化),尽管技术关键点在析构上而不是实例化上)。RAII类在标准库中很常见。比如STL容器(每个容器析构函数都销毁容器中的内容物并释放内存),标准智能指针(Item18-20解释了,std::uniqu_ptr
的析构函数调用他指向的对象的删除器,std::shared_ptr
和std::weak_ptr
的析构函数递减引用计数),std::fstream
对象(它们的析构函数关闭对应的文件)等。但是标准库没有std::thread
的RAII类,可能是因为标准委员会拒绝将join
和detach
作为默认选项,不知道应该怎么样完成RAII。
幸运的是,完成自行实现的类并不难。比如,下面的类实现允许调用者指定ThreadRAII
对象(一个std::thread
的RAII对象)析构时,调用join
或者detach
:
- class ThreadRAII {
- public:
- enum class DtorAction { join, detach }; //enum class的信息见条款10
-
- ThreadRAII(std::thread&& t, DtorAction a) //析构函数中对t实行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;
- };
Item17说明因为ThreadRAII
声明了一个析构函数,因此不会有编译器生成移动操作,但是没有理由ThreadRAII
对象不能移动。如果要求编译器生成这些函数,函数的功能也正确,所以显式声明来告诉编译器自动生成也是合适的:
- class ThreadRAII {
- public:
- enum class DtorAction { join, detach }; //跟之前一样
-
- ThreadRAII(std::thread&& t, DtorAction a) //跟之前一样
- : action(a), t(std::move(t)) {}
-
- ~ThreadRAII()
- {
- … //跟之前一样
- }
-
- ThreadRAII(ThreadRAII&&) = default; //支持移动
- ThreadRAII& operator=(ThreadRAII&&) = default;
-
- std::thread& get() { return t; } //跟之前一样
-
- private: // as before
- DtorAction action;
- std::thread t;
- };
thread
最终是不可结合的。join
会导致难以调试的表现异常问题。detach
会导致难以调试的未定义行为。std::thread
对象。Item37中说明了可结合的std::thread
对应于执行的系统线程。未延迟(non-deferred)任务的future(参见Item36)与系统线程有相似的关系。因此,可以将std::thread
对象和future对象都视作系统线程的句柄(handles)。
从这个角度来说,有趣的是std::thread
和future在析构时有相当不同的行为。在Item37中说明,可结合的std::thread
析构会终止你的程序,因为两个其他的替代选择——隐式join
或者隐式detach
都是更加糟糕的。但是,future的析构表现有时就像执行了隐式join
,有时又像是隐式执行了detach
,有时又没有执行这两个选择。它永远不会造成程序终止。这个线程句柄多种表现值得研究一下。
因为与被调用者关联的对象和与调用者关联的对象都不适合存储这个结果,所以必须存储在两者之外的位置。此位置称为共享状态(shared state)。共享状态通常是基于堆的对象,但是标准并未指定其类型、接口和实现。标准库的作者可以通过任何他们喜欢的方式来实现共享状态。
我们可以想象调用者,被调用者,共享状态之间关系如下图,虚线还是表示信息流方向:
共享状态的存在非常重要,因为future的析构函数——这个条款的话题——取决于与future关联的共享状态。特别地,
std::async
启动的未延迟任务建立的那个——的最后一个future的析构函数会阻塞住,直到任务完成。本质上,这种future的析构函数对执行异步任务的线程执行了隐式的join
。detach
。对于延迟任务来说如果这是最后一个future,意味着这个延迟任务永远不会执行了。
这些规则听起来好复杂。我们真正要处理的是一个简单的“正常”行为以及一个单独的例外。正常行为是future析构函数销毁future。就是这样。那意味着不join
也不detach
,也不运行什么,只销毁future的数据成员(当然,还做了另一件事,就是递减了共享状态中的引用计数,这个共享状态是由引用它的future和被调用者的std::promise
共同控制的。这个引用计数让库知道共享状态什么时候可以被销毁。对于引用计数的一般信息参见Item19。)
正常行为的例外情况仅在某个future
同时满足下列所有情况下才会出现:
std::async
而创建出的共享状态。std::launch::async
(参见Item36),原因是运行时系统选择了该策略,或者在对std::async
的调用中指定了该策略。std::future
,情况总是如此,对于std::shared_future
,如果还有其他的std::shared_future
,与要被销毁的future引用相同的共享状态,则要被销毁的future遵循正常行为(即简单地销毁它的数据成员)。只有当上面的三个条件都满足时,future的析构函数才会表现“异常”行为,就是在异步任务执行完之前阻塞住。实际上,这相当于对由于运行std::async
创建出任务的线程隐式join
。
std::async
启动的未延迟任务建立的那个——的最后一个future的析构函数会阻塞住,直到任务完成。