Effective modern C++ 条款 40:注意不同线程句柄的析构函数的行为

来源:互联网 发布:莱昂纳德体测数据虎扑 编辑:程序博客网 时间:2024/06/03 11:36
    Item 39提到,一个joinable的std::thread对象对应了一个执行线程。一个非延迟任务(见Item 38)的std::future对象与系统线程也有类似的关系。因此,std::thread和std::future对象都可以看作线程的句柄。
    从这个角度来看,std::thread和std::future的析构函数的区别如此之大就变得很有趣了。如Item 39所述,析构一个joinable的std::thread对象会导致进程终止,因为两种显而易见的方案--隐式调用join和隐式调用detach--都不是一个好的选择。然而,std::future对象的析构函数中,有时会隐式调用join,有时会隐式调用detach,而有时二者都不调用。它从不会导致进程终止。
    这个线程句柄的行为值得我们一探究竟。future是一个通信通道的一端,负责为被调用者传递运行结果给调用者,我们就从这个角色开始。被调用者(异步执行,延迟任务除外)将运行结果写入通信通道(通常通过std::promise对象),然后调用者通过future对象来读取这个结果。你可以认为这是一个如下所示的过程,其中虚箭头表示信息是从被调用者流向调用者的:

    但是被调用者的结果存在哪呢?被调用者可能在调用者触发get函数之前就结束了,所以结果不能存储于被调用者的std::promise对象里,因为它是被调用者的局部变量,会在调用结束之后被销毁。
    结果也不能存储于调用者的std::future对象中,因为它有可能被用来创建std::shared_future对象(这样一来便将被调用者运行结果的拥有权转移到std::shared_future对象),而这个对象在原始的std::future被销毁后有可能被复制多份。并且由于并不是所有的对象都是可复制的(如std::unique_ptr,见Item 20),而且被调用者的返回值的生命周期至少应该与相关的最后一个future对象一样长,那这么多个std::future对象,究竟选哪一个来存储这个结果呢?
     因为调用者和被调用者都不适合存储这个返回值,所以返回值被存储于一个共享态。通常这个共享态是由一个基于堆的对象,但是它的类型、接口及实现都没有标准的定义。标准库的作者可以选择任何他们喜欢的方式来实现。
    我们可以想象的到,调用者、被调用者以及共享态之间的关系如下图所示,其中虚线箭头表示信息的流向:

    共享态的存在很关键,因为std::future的析构函数--这章的主题--与其相关联的共享态是息息相关的。具体而言,
  • 最后一个与通过异步方式启动的std::async关联的std::future对象的析构会阻塞,直到任务结束。本质上,这个future的析构函数中隐式调用了任务的异步执行线程的join函数。
  • 其它所有的std::future的析构函数仅仅销毁future对象。对于异步执行的任务,这些对象都在析构函数中调用执行线程的detach函数。对于延迟任务,意味着这个任务永远不会执行。
    这些规则看上去比实际要复杂。实际我们只是处理一个正常的逻辑和它的一个异常情形。正常的逻辑就是future的析构函数里值销毁future对象,它既不join任何线程,也不detach任何线程。它只销毁future对象的成员变量,当然它还需要做的一件事就是减少共享态中的引用计数。通过这个引用计数,可以知道什么时候可以销毁这个共享态。关于引用计数详见Item 21。
    上述所说的对于一个future对象的异常情况只有一下所有条件都满足才会发生:
  • 这个对象关联着一个由调用std::async产生的共享态
  • std::async的任务启动策略是std::launch::async,要么是运行时系统自动选择或者由调用者显示指定
  • 这个对象是最后一个与共享态关联的future对象。对于std::future对象总是如此,但对于std::shared_future而言,只有所有与这个共享态关联的std::shared_future都已经被销毁,那最后这个std::shared_future对象销毁之后还需要销毁它的成员变量。
    只有当以上条件全部满足时,future对象的析构函数才执行特殊操作,这个特殊操作即指它进入阻塞状态,知道异步任务执行完毕。具体而言,即调用由std::async创建的std::thread对象的join函数。我们经常会听到“从std::async返回的std::future对象在析构时会阻塞”,这是第一印象,但是你不能仅仅依靠它,现在你就知道所有的真相。
    你或许想知道为什么对于异步启动任务的std::async所对应的共享态会需要这种特殊处理,这么问也很合理。据我所知,标准委员会想要避免由隐式调用detach所带来的问题,但它们有不想采用直接终止进程的这种激进的做法(如析构joinable的std::thread对象--再一次,详见Item 39),所以他们最后选择了隐式调用join。这个选择并非没有争论,事实上人们曾经争论要在C++ 14中放弃这种行为。但是最终并没有这么做。
    future并没有提供任何方式来查看它上是否与某个从std::async中产生的共享态关联,因此我们便无法知道它的析构函数中是否会阻塞直到异步任务执行结束。这有一些比较有意思的影响:
   
    // this container might block in its dtor, because one or more    // contained futures could refer to shared state for a non-    // deferred task launched via std::async    std::vector<std::future<void>> futs; // see Item 41 for info    // on std::future<void>    class Widget { // Widget objects might         public: // block in their dtors         …        private:        std::shared_future<double> fut;     };     void doWork(std::future<int> fut); // fut might block in its dtor                                                                                                // (see Item 17 for info on by-value params


当然,如果你有方法可以知道触发特殊的析构行为的条件没有全部满足(根据程序逻辑),那你变可以肯定future对象不会在析构函数中阻塞。例如,只有std::async中关联的共享态才会导致future在析构中阻塞,但是还有其他地方同样会产生共享态,其中一个地方就是std::packaged_task。一个std::packaged_task对象中有一个等待异步执行的函数(或其他执行体),它的执行结果也是放入共享态。与这个共享态关联的future对象可以通过std::packaged_task的get_future函数来获得:   
int calcValue(); // func to runstd::packaged_task<int()> // wrap calcValue so it    pt(calcValue); // can run asynchronouslyauto ptFut = pt.get_future(); // get future for pt
     一旦创建,std::packaged_task的任务pt可以在另外一个线程中执行,std::packaged_task不可拷贝,所以pt必须先转成右值引用,然后传到到std::thread的构造函数,这就保证它是被moved而不是拷贝到线程的数据区间:       
 std::thread t(std::move(pt));
   此时我们就能确定ptFut并没有关联因为调用std::async而产生的共享态,所以它的析构函数中不会阻塞。因此我们也就不必担心调用doWork时,当doWork的参数fut析构时会阻塞等待线程t结束了。
    std::packaged_task::get_future返回一个std::future对象,它只能能够被move,所以为了使调用doWork能够通过编译,我们必须对ptFut使用move操作,就像我们传递pt给std::thread一样:       
doWork(std::move(ptFut));
    事实上可以通过这个例子看到futrure正常的析构行为,但是把所以代码放在一起会更加清晰一点:   
 { // begin scope     std::packaged_task<int()> // as above                pt(calcValue);     auto ptFut = pt.get_future(); // as above     std::thread t(std::move(pt)); // as above     doWork(std::move(ptFut)); // as above     … // see below  } // end scope
    最有意思的代码是"..."即在doWork调用之后,“}”之前的那段。之所以有意思是在这里面,std::thread究竟会发生什么。有三种可能的情况会发生:
  • 什么都不发生。这种情况在域结束时,t将会是joinable的,这会导致程序终止。
  • 在t上调用过join。这种情况ptFut已经没有必要再析构函数中阻塞了,因为join已经在代码中显示的调用了。
  • 在t上调用了dtach。这种情况ptFut没有必要再析构函数中调用detach,因为它已经在代码中被显示的调用了。
    换句话说,当你有一个与std::packaged_task产生的共享态相关联的future对象时,通常没有必要在它的构造函数中采用特殊的操作。因为在管理std::packaged_task实际运行的std::thread的代码已经在终止进程、join、detach之间做了选择。
 重点回顾
  • future的析构函数通常只销毁future的成员变量
  • 最后一个与异步启动任务的std::async产生的共享态相关联的future对象会在析构函数中阻塞直到任务完成。   
0 0
原创粉丝点击