effective c++ 条款37 在所有路径上,确保std::threads unjoinable

每个std::thread都有两个状态:joinable和unjoinable。每一个可以join的线程对象都关联一个处于运行或者可以运行状态的底层异步线程。对于关联处于阻塞或者等待调度状态的底层线程的线程对象是可以join的。例如关联的底层线程运行结束了,也是可以join的。

不可join的线程对象如你所期望的,包含:

默认构造的线程对象 没有功能函数,故而没有关联底层可以运行的线程,所以不可join。

被move的线程对象, 底层线程关联到了两外一个std::thread对象上了,所以被move的std::thread不关联底层线程,不可join。

已经被join的线程对象,关联的底层线程已经运行结束,不再关联。

detach的线程,底层线程和线程对象已经分开。

 

线程对象的join功能很重要,这是因为当其析构函数调用时,程序就终止了。设想我们有一个doWork函数,它有两个参数,一个是过滤函数,另外一个是个整形参数,maxVal表示参数最大值。doWork先确保条件满足,然后计算,计算过程是将0-maxVal之间的数据传递给过滤函数。如果过滤过程很耗时,而且判断条件是否满足也会很耗时,那么就有必要并发的执行这两个过程。

我们的最佳选择是基于任务的设计。但设想我们需要指定线程的优先级,就需要线程API,而基于任务的设计就无法提供,就必须使用基于线程的设计。可以写下面代码:

constexpr auto  tenMillion = 10000000;

bool doWork(std::function<bool(int)> filter,

                       int maxVal = tenMillion)

{

          std::vector<int> goodVals;

          std::thread t([&filter, maxVal, &goodVals]{

            for(int i = 0; i < maxVal; i++)  {

              if (filter(i)) goodVals.push_back(i);

          });   

          if (conditionsAreSatisfied( )) {

              t.join();

              performComputation(goodVals);

              return true;

          }

           return false;

}

当条件满足时候,线程对象被join,一切正常结束。而如果条件返回false或者抛出异常,线程对象在析构函数中为joinable,程序会终止。

你可能想知道,为啥std::thread析构函数设计成这样。这是因为另外两个显而易见的选项更拙劣。它们是:

隐式join。这种情况下析构函数会等待关联的底层异步线程执行完毕。这听起来有道理,但会引起效率异常问题,而且难以排查。比如,在conditionsAreSatisfied已经返回false情况下,继续等待对所有值的filter执行结果,就不合理。

隐式detach。这种情况下,线程的析构函数会切断线程对象和底层线程之间的联系。底层线程继续运行。这听起来也不比join的设计方案好多少,却更带来的调试困难问题更为严重。比如,goodVals是一个局部变脸, 通过引用方式捕获。它在lamda中可以被改变(通过push_back). 设想当lamda正异步执行,conditionsAreSatisfied返回了false。这种情况下,doWork应该返回,局部变量goodVals也应该销毁。它的栈帧会弹出,运行的线程却会继续留在doWork的调用点。

调用点以下的语句也许会有函数调用。这可能会访问到doWork的栈帧所在的内存,就可能会导致程序运行结束。我们称呼这个函数f,当f执行的时候,doWork初始化的lamda仍在运行,lamda可能会对goodVals曾经占用的内存调用push_back。因为goodVals曾经占用的内存现在属于f的栈帧。从f角度看,它的栈帧自己改变了!想向你在调试这个问题时多有趣。

因为销毁一个可join的线程对象后果很严重,标准委员会禁止了这一做法。(明确规定可join的线程析构时终止程序)。

这就把std::thread成为不可join的职责转交到你的手上,而且要在每条离开作用域的路径上确保。但覆盖每条路径会很复杂。包含了return,continue,break,goto和异常,这些路径非常多。

每次你想在每条路径执行某个行为时,通常的方案是将这一行为封装在局部对象的析构函数中。这一局部对象被成为RAII对象,类被成为RAII类。(RAII本意指资源在初始化时候获取,关键却是析构函数中释放资源)RAII在标准库中很常见,STL的容器(每个容器都在析构函数中释放资源,销毁内容)。标准的智能指针,std::fstream(析构函数关闭文件)。但对于std::thread却没有标准的RAII,这是因为标准委员会同时否定了join和detach作为默认行为的方案,线程对象就不知道应该怎么做了。

幸运的是,自己写一个也不太困难。比如下面的类就允许用户自己定义ThreadRAII析构时,选择join还是detach。

class ThreadRAII {

  public:

      enum class DtoAction{join, detach};

      ThreadRAII(std::thread &&t, DtoAciton a)

         :action(a), t(std::move(t)){

        }

      ~ThreadRAII() {

         if () {

           if (t.joinable()) {

                 if (action == DtoAction::join) {

                      t.join();  

                  } else {

                     t.detach();

                   }      

           }

private:

     DtoAction action;

     std::thread t;

};

解释一下:

构造函数只接受一个std::thread右值,因为std::thread无法拷贝,只有move。

参数顺序符合用户直觉。(构造在前,析构在后)。在初始化列表中,为了与声明顺序一致,std::thread

放在了最后。在这个例子无影响。但更一般的情况,变量初始化存在依赖关系,std::thread在初始化后,会立即投入运行。因此将std::thread对象放在最后,是一个好的习惯。这保证了所有与之相关的成员变量都初始化了,因此可以安全的执行关联的异步线程。

在洗后函数中,先测试std::thread是否可join。这是必要的,因为对不可join对象做join或者detach操作会出席那未定义行为。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值