前面说过,可联结的线程对应着一个底层系统执行线程,未推迟任务的期值和系统线程也有类似关系。这么一来,std::thread型别对象和期值对象都可以视作系统线程的句柄。针对可联结的std::thread型别对象实施析构会导致程序终止,因为另外两个显而易见的选择(隐式join或者detach)都被认为是更糟糕的选择。
但是期值的析构函数呢?有的时候行为像是执行了一次隐式join,有的时候像是执行了一次隐式detach,有的时候像是两者都没有执行。它从不会导致程序终止。这套线程句柄行为的大杂烩,值得细品。
期值位于信道的一端,被调方把结果通过该信道传输给调用方。被调方(通常以异步方式运行)把其计算所得的结果写入信道(通道经由一个std::promise型别对象),而调用方则使用一个期值来读取该结果。
简单说来,可以把这个过程理解成下面的这个图,虚线箭头表示从被调方向调用方的信息流:
但被调方的结果要存储在哪里呢?在调用方唤起对应期值的get之前,被调方可能已经执行完毕,因此结果不会存储在被调方的std::promise对象里。那个对象,对于被调方来说是个局部量,在被调方结束后会实施析构。
该结果也不能存储在调用方的期值中,因为(出于其他种种原因)可能会从std::future型别对象触发创建std::shared_future型别对象(因此把被调方结果的所有权从std::future型别对象转移至std::shared_future型别对象),而后者可能会在原始的std::future析构之后复制多次。如果被调方的结果型别不都是可复制的(即只移型别),而该结果至少生存期要延至和最后一个指涉到它的期值一样长。这么多对应同一结果的期值中的哪一个,应该包含该结果呢?
已经否定了上述两者的可能性,那么被调方实际上存在两者之外的某个位置。这个位置成为共享状态。共享状态通常使用堆上的对象来表示,但是其型别,接口和实现标准皆未指定。标准库作者可以自由地用他们的喜好的方式去实现共享状态。
那么上述图就是这样的:
共享状态的存在很重要,因为期值析构函数行为(这也是本条的议题)是由与其关联的共享状态决定的,总体来说:
- 指涉到经由std::async启动的未推迟任务的共享状态的最后一个期值会保持阻塞,直到该任务结束。本质上,这样一个期值的析构函数是对底层异步执行任务的线程实施了一次隐式join。
- 其他所有期值对象的析构函数只仅仅将期值对象析构就结束了。对于底层异步运行的任务,这样做类似于对线程实施了一次隐式detach。对于那些被推迟任务而言,如果这一期值是最后一个,也就意味着被推迟的任务将不会有机会运行了。
这些规则听上去复杂,其实不然。我们真正要关心的,是一个平凡的“常规”行为外加一个不常见的例外而已。
- 常规行为:期值的析构函数仅会析构期值对象。就这样。它不会针对任何东西实施join,也不会从任何东西实施detach,也不会运行任何东西。它仅会析构期值的成员变量。(另一个无关紧要的操作,它还多做了一件事,它针对共享状态里的引用计数实施了一次自减。该共享状态由指涉到它的期值和被调方的std::promise共同操纵。该引用计数使得库能够知道何时可以析构共享状态。关于引用计数材料,参见Item 19)。
- 例外行为:而相对正常行为的那个例外,只有在期值满足以下全部条件时才会发挥作用:
- 期值所指涉的共享状态是由于调用了std::async才创建的。
- 该任务的启动策略是std::launch::async,参见Item 36,这级可能是运行时系统的选择,也可能是在调用std::async时指定的。
- 该期值是指涉到该共享状态的最后一个期值。对于std::future型别对象而言,这一点总是成立。而对于std::shared_future型别对象而言,在析构时如果不是最后一个指涉到共享状态的期值,则它会遵循正常行为准则(即仅析构其成员变量)。
只有当所有条件都满足,期值的析构函数才会表现出特别行为。而行为的具体表现为:
- 阻塞直到异步运行的任务结束。
从效果来看,这相当于针对正在运行的std::async所创建的任务的线程实施了一次隐式join。
你可能会问“为什么要为从std::async触发启动的非推迟任务相关联的共享状态专门制定一条规则?”
这个问题十分合理。根据我所知道的,标准委员会想要避免隐式detach相关的问题(参见Item 37),但他们又不想简单粗暴地让程序终止了事(他们针对可联结线程就是这样做的,参见Item 37),所以妥协结果就是实施一次隐式join。
这个决定并非没有争议,委员会也曾认真讨论过要在C++14中舍弃这样的行为。但是最后没有做出改变,所以期值析构函数的行为在C++11和C++14中保持了一致的。
期值的API没有提供任何方法判断其指涉的共享状态是否诞生于std::async的调用,所以给定任意期值对象的前提下,它不可能知道自己是否会在析构函数中阻塞到异步任务执行结束。
这个事实暗示着一些意味深长的推论:
// 该容器的析构函数可能会在其析构函数中阻塞
// 因为它所持有的期值中可能会有一个或多个
// 指涉到经由std::async启动未推迟任务所产生的共享状态
std::vector<std::future<void>> futs;
class Widget {
public:
.... // Widget型别对象可能会在其析构函数中阻塞
private:
std::shared_future<double> fut;
};
当然,如果有办法判定给定的期值不满足触发特殊析构行为的条件(例如,通过分析程序逻辑),即可断定该期值不会阻塞在其析构函数中。例如,只有因std::async调用而出现的共享状态才够格去展示特别行为,但还有其他方式可以创建出共享状态。
其中一个方法就是运用std::packaged_task,std::packaged_task型别对象会准备一个函数(或其他可调用的对象)以供异步执行,手法是将它加上一层包装,把其结果置入共享状态。而指涉到该共享状态的期值则可以经由std::packaged_task的get_future函数得到:
int calcValue(); // 待运行函数
std::packaged_task<int()> pt(calcValue); // 给calcValue加上使之能以异步方式运行
auto fut = pt.get_future(); // 取得pt的期值
此时此刻,我们已知期值对象fut没有指涉到由std::async调用产生的共享状态,所以它的析构函数将表现出正常行为。
std::packaged_task不能复制,所以欲将pt传递给std::thread的构造函数就一定要将它强制转型到右值(经由std::move,参见Item 23):
std::thread t(std::move(pt)); //在t之上运行pt
此例让我们能够隐约看出一些期值的常规析构行为,但如果把这些语句都放在统一代码块中,就可以看得更清楚:
{ //代码块开始
std::packaged_task<int()> pt(calcValue); // 给calcValue加上使之能以异步方式运行
auto fut = pt.get_future(); // 取得pt的期值
std::thread t(std::move(pt));
... //见下
} //代码块结束
这里最值得探讨的代码是"…“部分,它位于t创建之后、代码块结束之前。值得探讨的是,在”…"中t的命运如何。基本存在三种可能:
- 未对t实施任何操作。在这种情况下,t在作用域结束点是可联结的,而这将导致程序终止(参见Item 37)。
- 针对t实施了join。在此情况下,fut无需在析构函数中阻塞,因为在调用的代码已经有过join。
- 针对t实施了detach。在此情况下,fut无需在析构函数中实施detach,因为在调用的代码已经做过这样的事情了。
换句话说,当你的期值所对应的共享状态是由std::packaged_task产生的,则通常无需采用特别析构策略。因为,关于是终止,联结还是分离的决定,会由操纵std::thread的代码作出,而std::packaged_task通常就运行在该线程上。