Effective modern C++ 条款 39:让std::thread在所有路径上不可join(Make std::threads unjoinable on all paths)

    每个std::thread对象都处于两种状态之一:joinable和unjoinable。一个joinable的std::thread对象对应一个正在运行或处于可以运行状态的异步执行的线程。例如,一个处于阻塞状态或者等待被调度的线程是joinable的。
    一个unjoinable的线程正如你所期待的:它不可以被join。unjoinable的std::thread对象包括:
  • 默认构造的std::thread对象。这个线程对象没有执行体,因此不对应一个执行的线程。
  • 被move的std::thread对象。被move的结果是原本对应于std::thread对象的执行线程被另外一个std::thread接管,原来的std::thread对象不再对应于这个执行线程。
  • 已经被join的std::thread。std::thread对象被join后,其对应的执行线程已经执行完毕。
  • 被分离(detached)的std::thread对象。分离之后,std::thread与执行线程之间的连接关系被切断。
    之所以线程是否能够被join很重要,是因为当一个可以被join的std::thread被析构时,程序会被迫终止。
    例如,假设我们有一个函数doWork,它接受一个过滤器函数filter,和一个最大值maxVal作为参数。doWork检查确认所有条件满足之后,开始将所有[0,maxVal)的数据传入filter进行过滤,然后进行计算。如果进行过滤很耗时,同时检查条件是否满足也很耗时,那将这二者并行执行则是合情合理的。我们可能会写出如下的实现:
        
constexpr int tenMillion = 10000000; // see Item 14 
        // for constexpr
        bool doWork(std::function<bool(int)> filter,     // returns whether 
                             int maxVal = tenMillion)         // computation was
        { // performed
             std::vector<int> vals; // values that
             // satisfy filter
             std::thread t([&filter, maxVal, &vals] // compute vals'
             { // content
                  for (auto i = 0; i <= maxVal; ++i)
                  {
                       if (filter(i)) vals.push_back(i);
                  }
              });
             if (conditionsAreSatisfied()) {
              t.join(); // let t finish
              performComputation(vals);
              return true; // computation was
         } // performed
         return false; // computation was not performed
        };

    在我解释以上的代码为什么存在问题时,首先说明在C++ 14中 tenMillion 的初始化的可读性会更好,这要得益于C++ 14中可以利用撇号作为数据分隔符:     
constexpr auto tenMillion = 10'000'000; // C++ 14 only
    回到doWork,如果conditionsAreSatisfied()返回true,一切都没事,但是如果它返回false或者抛出一个异常,那doWork结束时,std::thread对象的析构函数被调用,但t仍然是一个可join的对象,这会导致程序终止。
    你也许会问,为什么std::thread的析构函数要有这种行为呢?这是因为另外两种显而易见的方案的争论都很大,它们是:
  • 隐式的调用join。这种情况下,std::thread的析构函数会等待它的执行线程执行结束。这听起来很合理,但是这会导致一些很难跟踪的异常行为。比如,即使conditionsAreSatisfied()返回false,doWork依然要等待所有的值都进行filter调用。
  • 隐式分离。这种情况下,std::thread的析构函数中会断开对象与执行线程之间的连接,此时执行线程会继续运行。这看上去与隐式调用join一样合理,但是它所能够引起的调试问题甚至更糟。例如,在doWork中,vals变量都是局部变量的引用,同时它还在lamada表达式中进行了修改(通过push_back)。假设这个lamada表达式在执行的过程中,conditionsAreSatisfied()返回false,这时doWork会返回,它的所有局部变量会被销毁(包括vals),它的栈空间会被弹出,但它的执行线程却仍然在运行。在某种情况下,在接下来的程序中,会继续调用其它函数。而总有那个一个调用会使用曾经被doWork使用的栈空间,我们不妨将这个被调用函数称之为 f。在f运行时,在doWork中的lamada仍然在异步执行,它会在曾经vals占用的内存空间上调用push_back,但现在这个空间是在f的栈空间上。这个调用会修改曾经有vals占用的空间,这意味着从f的角度看,它的栈空间上的内存会自发的修改!想想你调试这个问题的乐趣吧。
    由于析构一个可join的std::thread对象所造成的后果十分可怕,因此标准委员会实际上禁止这种行为(通过终止程序)。 这就要求你在使用std::thread对象时,在利用其作用域的所有路径上都保证它是不可join的。但是覆盖所有路径并非易事,它包括return、continue、break、goto或异常等能够跳出作用域的情况,这将会是非常多的路径。
    无论你什么时候想在所有出作用域的地方做某件事,最常见的方法就是将这件事放入这个局部对象的析构函数中。这些对象称之为RAII对象,而它们的类称之为RAII类,RAII表示“资源获取即初始化”,尽管这种技术的关键是析构而非初始化。RAII类在标准库中比较常见。例如,STL的容器(每个容器的析构函数都销毁它的内容并释放内存)、标准的智能指针(Item 20-22解释了std::unique_ptr的析构函数调用它指向的对象的delter,std::shared_ptr和std::weak_ptr减少引用计数)、std::fstream对象(它们的析构函数会关闭它们对应的文件),等等。尽管如此,std::thread并不是RAII类,也是是因为标准委员会拒绝了join和detach作为默认的选项,但他们也不知道具体该怎么做。
    幸运的是,自己去实现一个并不困难。例如下面的类允许调用者指定一个std::thread的成员函数(即join或者detach),这个函数会在一个ThreadRAII对象(一个std::thread的RAII对象)析构时被调用:
class ThreadRAII
{
public:
 using RAIIAction =
  void(std::thread::*)();

 ThreadRAII(std::thread&& t, RAIIAction action)
  :action(action), t(std::move(t))
 {}

 ~ThreadRAII()
 {
  if (t.joinable())
   (t.*action)();
 }

 std::thread& get(){ return t; }
private:
 RAIIAction action;
 std::thread t;
};
    虽然以上的代码基本上很清楚,但是以下几点解释还是有必要的:
  • 构造函数只接受std::thread的右值引用,因为我们需要将传入的std::thread对象move到ThreadRAII对象
  • 构造函数的参数次序的设计对调用者很直观(先指定std::thread,然后指定析构函数需要调用的方法,这比反过来更能说的通),但是在成员初始化列表是与成员变量的声明顺序一致的。这个顺序是最后来设置thread对象。 在这个例子中,两个顺序并没有区别,但是通常一个成员变量的初始化依赖于另外一个成员的情况是存在的。并且由于一个thread对象可能在初始化后便立即运行,所以在类中最后来初始化std::thread对象绝对是个好习惯。这样能够保证它们被构造之后,所有其他的成员变量都已经初始化,然后std::thread对应的执行线程就能够安全的访问所有的变量。
  • ThreadRAII提供一个get成员函数来获取它所对应的std::thread对象。这模仿了智能指针的get方法。提供get方法使ThreadRAII类可以避免实现所有std::thread的接口,这也意味着ThreadRAII可以用于所有需要std::thread的地方,即那些需要std::thread引用的接口。
  • 在ThreadRAII析构函数在std::thread对象上触发action之前,它检查了std::thread是否joinable。这是必要的,因为在unjoinable的std::thread对象上调用join或detach会导致未定义行为发生,并且有可能一个人他构造了一个std::thread对象,并创建一个ThreadRAII对象,然后通过它的get方法获得了这个std::thread对象,并且将这个对象move到了另外一个std::thread对象上,这也会导致原来的std::thread unjoinable。
    如果你担心     
if (t.joinable()) (t.*action)();
    这行代码在存在竞争条件,因为在t.joinable()和(t.*action)()之间,另外一个线程会将t变成unjoinble。你的观察值得赞美,但是这样的担心是没有必要的。因为std::thread对象只有通过调用它的成员函数join、detach或者move操作,它的状态才会由joinable变为unjoinable。但是在析构ThreadRAII对象时,不会有其它线程再调用这个对象的成员函数。如果存在并行的调用,那确实存在竞态,但是这个竞争条件并不在析构函数中,而在尝试同时调用一个对象的两个成员函数(析构函数和其他)的客户代码中。通常,只有同时调用的成员函数都是const成员,调用才是安全的(详见Item 15)。
    我们这里定义的ThreadRAII类使用示例如下:
bool doWork(std::function<bool(int)> filter, // as before
   int maxVal = tenMillion)
{
 std::vector<int> vals; // as before
 ThreadRAII t(
    std::thread([&filter, maxVal, &vals] // use RAII object
    {
     for (auto i = 0; i <= maxVal; ++i)
     { if (filter(i)) vals.push_back(i); }
    }),
    &std::thread::join // RAII action
    );
 if(conditionsAreSatisfied()) {
  t.get().join(); // let t finish
  performComputation(vals);
  return true;
 }
return false;
}

    在这个例子中,我们选择在ThreadRAII析构函数中调用异步执行的线程的join函数,因为前面提到,如果选择detach才是调试的噩梦的开始。前面也提过,调用join会影响程序效率(老实说,这也会让调试不那么愉快),但是在为定义行为、程序终止、性能下降之间做选择,性能下降貌似是最好的选择了。
    哎,可惜正如Item 41中指出的,使用ThreadRAII在析构中调用std::thread对象的join不仅会导致性能异常,还可能导致程序无法结束。其实这些问题最好的解决方案是可以告知异步执行的lambda我们不需要它继续运行了,这样它可以提早退出。但是C++ 11和C++ 14均没有提供这种中断机制。这些我们可以手动实现,但是这超出了本书的讨论范围。
    根据Item 19中的解释,由于ThreadRAII声明了析构函数,因此编译器不会生成默认的move操作函数,但是ThreadRAII对象怎能不可move呢。如果编译器生成这些函数,那么这些函数会做它们应该做的事,所以我们可以让编译器为我们创建出这些函数:
class ThreadRAII
{
public:
 using RAIIAction =
  void(std::thread::*)();

 ThreadRAII(std::thread&& t, RAIIAction action)
  :action(action), t(std::move(t))
 {}

 ~ThreadRAII()
 {
  if (t.joinable())
   (t.*action)();
 }

ThreadRAII(ThreadRAII&& rhs) = default;
 ThreadRAII & operator=(ThreadRAII&& rhs) = default;

 std::thread& get(){ return t; }
private:
 RAIIAction action;
 std::thread t;
};

    类ThreadRAII帮助避免程序因为处于joinable的std::thread对象的析构提前结束,但是在析构函数中调用join或是detach所带来的问题兴许会让你遵从Item 37的建议,在一开始就远离std::thread。这么做的好处是你只需与std::future打交道,并且std::future对象不会再它的析构函数中触发std::terminate。然而它在析构函数中所做的事也值得单独用一章来讨论,具体我们将在Item 40中进行讨论。
重点回顾
  • 保证std::thread在所有路径上都是unjoinable
  • 在析构函数中join线程,会导致性能异常
  • 在析构函数中detach线程,会导致未定义行为
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值