Item 37: Make std::threads unjoinable on all paths.

Item 37: Make std::threads unjoinable on all paths.

每个 std::thread 只会处于两种状态状态之一:其一为 joinable,其二为 unjoinable 。一个 joinablestd::thread 对应于一个正在或可能在运行的底层线程。例如,一个对应于处于阻塞或者等待调度的底层线程的 std::threadjoinable。对应于底层线程的 std::thread 已经执行完成也可以被认为是 joinable

unjoinable 的线程包括:

  • 默认构造的 std::thread。这样的 std::thread 没有执行函数,也就不会对应一个底层的执行线程。
  • std::thread 对象已经被 move。其底层线程已经被绑定到其它 std::thread
  • std::thread 已经 join。已经 join 的对应 std::thread 的底层线程已经运行结束。
  • std::thread 已经 detach。已经 detachstd::thread 与其对应的底层线程已经没有关系了。

std::threadjoinabilty 状态之所以重要的原因之一是:一个 joinable 状态的 std::thread 对象的析构函数的调用会导致正在运行程序停止运行。例如,我们有一个 doWork 函数,它接收一个过滤函数 filter 和一个最大值 MaxVal 作为参数。 doWork 检查并确定所有条件满足时,对 0 到 MaxVal 执行 filter。对于这样的场景,一般会选择基于任务的方式来实现,但是由于需要使用线程的 handle 设置任务的优先级,只能使用基于线程的方法来实现(相关讨论可以参见 Item 35: Prefer task-based programming to thread-based.)。可能的实现如下:

constexpr auto tenMillion = 10000000; // see Item 15 for constexpr
bool doWork(std::function<bool(int)> filter, // returns whether
            int maxVal = tenMillion)         // computation was
{                                            // performed; see
                                             // Item 2 for
                                             // std::function
  std::vector<int> goodVals;  // values that
                              // satisfy filter
  std::thread t([&filter, maxVal, &goodVals]  // populate
                {                             // goodVals
                  for (auto i = 0; i <= maxVal; ++i)
                  { if (filter(i)) goodVals.push_back(i); }
                });
  auto nh = t.native_handle();      // use t's native// handle to set
                                    // t's priority
  if (conditionsAreSatisfied()) {
    t.join();                       // let t finish
    performComputation(goodVals);
    return true;                    // computation was
  }                                 // performed
  return false;                     // computation was
}                                   // not performed

对于上面的实现,如果 conditionsAreSatisfied() 返回 true,没有问题。如果 conditionsAreSatisfied() 返回 false 或抛出异常,std::thread 对象处于 joinable 状态,并且其析构函数将被调用,会导致执行程序停止运行。

你可能会疑惑为什么 std::thread 的析构函数会有这样的行为,那是因为其他两种选项可能更加糟糕:

  • 隐式的 join。析构函数调用时,隐式去调用 join 等待线程结束。这听起来似乎很合理,但会导致性能异常,并且这有点反直觉,因为 conditionsAreSatisfied() 返回 false 时,也即条件不满足时,还在等待 filter 计算完成。
  • 隐式 detach。析构函数调用时,隐式调用 detach 分离线程。doWork 可以快速返回,但可能导致 bug。因为 doWork 结束后,其内部的 goodVals 会被释放,但线程还在运行,并且访问 goodVals ,将导致程序崩溃。

由于 joinable 的线程会导致严重的后果,因此标准委员会决定禁止这样的事情发生(通过让程序停止运行的方式)。这就需要程序员确保 std::thread 对象在离开其定义的作用域的所有路径上都是 unjoinable 。但是想要覆盖所有的路径并非易事,return、continue、goto、break 或者异常等都能跳出作用域。

无论何时,想在出作用域的路径上执行某个动作,常用的方法是将这个动作放入到一个局部对象的析构函数中。这种对象被成为 RAII(Resource Acquisition Is Initialization)对象,产生这个对象的类是 RAII 类。RAII 类在标准库中很常见,例如 STL 容器(每个容器的析构函数销毁容器中的内容并释放它的内存)中的智能指针(std::unique_ptr 析构函数调用它的 deleter 删除它指向的对象,std::shared_ptrstd::weak_ptr 的析构函数中会减少引用计数)、std::fstream 对象(析构函数关闭相应的文件)。但是 std::thread 对象没有标准的 RAII 类,这可能是标准委员会拒绝将 joindetach 作为默认选项,因为他们也不知道这个类应该有什么样的行为。

好在实现这样的一个类也并非难事。例如,你可以让用户指定 ThreadRAII 类在销毁时选择 join 还是 detach

class ThreadRAII {
public:
  enum class DtorAction { join, detach };    // see Item 10 for
                                             // enum class info
  ThreadRAII(std::thread&& t, DtorAction a)  // in dtor, take
  : action(a), t(std::move(t)) {}            // action a on t

  ~ThreadRAII()
  {
    if (t.joinable()) {                     // see below for
                                            // joinability test
      if (action == DtorAction::join) {
        t.join();
      } else {
        t.detach();
      }
    }
  }
  
  std::thread& get() { return t; }         // see below

private:
  DtorAction action;
  std::thread t;
};

关于上面代码的几点说明:

  • 构造函数只接收 std::thread 的右值,因为 std::thread 不可拷贝。
  • 构造函数参数排列顺序符合调用者的直觉(std:thread 为第一个参数,DtorAction 为第二个参数),但是成员变量的初始化符合成员变量的申明顺序。在这个类中两个成员变量的前后顺序没有意义,但是通常而言,一个成员的初始化依赖另一个成员。
  • ThreadRAII 提供了 get 函数,用于访问底层的 std::thread 对象。提供 get 方法访问 std::thread,避免了重复实现所有 std::thread 的接口。
  • ThreadRAII 的析构函数首先检查 t 是否为 joinable 是必要的,因为对一个 unjoinable 的线程调用 joindetach 将产生未定义的行为。

ThreadRAII 应用于 doWork 的例子上:

bool doWork(std::function<bool(int)> filter,
            int maxVal = tenMillion)
{
  std::vector<int> goodVals;
  
  ThreadRAII t(                           // use RAII object
    std::thread([&filter, maxVal, &goodVals]
                {
                  for (auto i = 0; i <= maxVal; ++i)
                    { if (filter(i)) goodVals.push_back(i); }
                }),
    ThreadRAII::DtorAction::join          // RAII action
  );

  auto nh = t.get().native_handle();
  ...
  if (conditionsAreSatisfied()) {
    t.get().join();
    performComputation(goodVals);
    return true;
  }
  return false;
}

这个例子中,我们选择 join 作为 ThreadRAII 析构函数的动作。正如前文所述,detach 可能导致程序崩溃,join 可能导致性能异常。两害取其轻,性能异常相对可以接受。

正如 Item 17: Understand special member function generation. 所介绍的,由于 ThreadRAII 自定义了析构函数,编译器将不在自动生成移动操作,但没有理由让 ThreadRAII 对象不支持移动。因而,需要我们将移动操作标记为 default

class ThreadRAII {
public:
  enum class DtorAction { join, detach };
  
  ThreadRAII(std::thread&& t, DtorAction a)
  : action(a), t(std::move(t)) {}

  ~ThreadRAII()
  {
    ...  // as before
  } 

  ThreadRAII(ThreadRAII&&) = default;            // support
  ThreadRAII& operator=(ThreadRAII&&) = default; // moving

  std::thread& get() { return t; }               // as before
  
private:
  DtorAction action;
  std::thread t;
};

至此,本文结束。

参考:

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值