前言
在上一节中,我们已经解释过一个状态为joinable的std::thread
对象对应着一个系统底层线程。基于non-defered task的std::future
对象与之类似,也对应着一个系统底层线程,因此,我们可以认为它们都是系统线程的句柄。
同样,我们在上一节中已经解释过析构状态为joinable的thread会导致terminate,但future并非如此,它的析构行为有时看起来像隐式join,有时看起来像隐式detach,又有时并不与它们类似。此外,对future的析构永远不会导致terminate。
共享状态
为了探究原理,我们首先对std::future进行一次细致地观察。future可被视为通信信道的一端,callee通过该信道将结果发送给caller。callee(一般其处于异步运行状态)将其计算结果写入通信通道(一般通过std::promise对象),caller使用future来读取该结果。总之,它们的行为大致可以用下图表述,其中虚线箭头表征从callee到caller的信息流:
你或许会好奇结果保存在何处。首先肯定不能放在std::promise对象之中,因为结果可能是在调用future对象获取时早已保存起来了,由于callee运行结束时会将std::promise析构,因此promise并不能承担保存结果的重任。
那么能否将结果放在std::future对象之中呢?这同样不行,因为std::future可以用来创建std::shared_future,因此需要将结果保存在每一个future对象中,这一行为将导致该结果被多次拷贝和复制,或许可以使用引用计数的方式来记录当前有多少个future对象关联到这个结果中,但无论何种方式都会造成一定的开销,算不上最优解。
因为与callee关联的对象和与caller关联的对象都不是存储结果的合适位置,所以结果被存储于这二者之外,此位置称为共享状态(shared state)。共享状态通常由heap-based对象表示,但其类型,接口和实现均没有标准形式,全凭标准库作者喜好决定。
我们可以设想callee,caller与共享状态之间的关系如下所示,其中虚线箭头再次表示信息流:
共享状态与future析构函数
共享状态的存在十分重要,因为future析构函数的行为由与future相关联的共享状态息息相关,具体来说:
- 最后一个关联到由std::async启动的non-defered task的共享状态的future,其析构函数将一直阻塞至该task完成为止。
从本质上来看,这种future的析构函数在运行异步执行task的线程上执行隐式join。 - 不满足条件1的future执行正常析构
对于异步运行的task,这相当于在底层线程上执行隐式detach。对于deferred策略运行的task来说,这相当于这个task将不会运行。
这两条规则听起来很复杂,但实际上很简单,我们真正需要处理的只是一个简单的“正常”行为和一个例外而已。所谓的正常行为就是future的析构函数直接析构future对象,它既不执行join,也不执行deatch,也不做其他乱七八糟的事情,它唯一做的事情就是析构了future的data member(实际上它还减少了共享状态的引用计数)。
例外指的是同时满足下述3个条件的future:
- 该std::future通过std::async创建,并且指向了一个共享状态。
- 该task的启动策略为std::launch::async。
- 该future是最后一个指向该共享状态的future。
只有满足上述三个条件的情况下future的析构函数才会被阻塞至task完成,也就是相当于隐式join。
std::future的这种特殊的析构行为让我们的程序行为变得不可预测,特别是我们没有办法知道哪个 future会隐式join,哪些又将detach。尽管如此,但是我们知道,凡是从std::async创建的std::future都有可能隐式join,而其他方式创建的std::future对象则不是,比如通过std::packaged_task创建的std::future,其析构就不会隐式join。
总结
- 通常情况下,future的析构函数将破坏其data member。
- 在特殊情况下future的析构函数类似于隐式join。