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

任何一个 std::thread 对象要么是 joinable (可联结)状态,要么是unjoinable(不可联结)状态。一个 joinablestd::thread 对应于一个正在运行或可能在运行的底层线程。比如,一个对应于处于阻塞或者等待调度的底层线程的 std::thread 是 joinable的,而且已经执行完成的 std::thread 也是joinable的。

unjoinable 状态的 std::thread 对象包括:

  • 默认构造的 std::thread。 因为这样的 std::thread 对象没有可执行的函数,因此没有与之相对应的底层线程;
  • 已经没 movestd::thread 对象。 这个很好理解,std::threadmove 后,其底层线程就被绑定到其他的 std::thread 了;
  • 已经被 joinstd::thread 。 虽然已经执行完成的 std::thread 可以是joinable的。但是 join 后,std::thread 就不再对应这个已经执行完成的底层线程了;
  • 已经被 detachstd::thread。已经 detachstd::thread 与其对应的底层线程已经没有关系了;

std::threadjoinability(可联结性)非常重要的一个原因是,如果一个 joinable 状态 线程的析构函数被调用,则程序就终止执行了(查看源码得知:直接调用了std::terminate())。 举个例子,假设我们有一个 doWork 函数,它接收一个过滤函数 filter 和一个最大值 MaxVal 作为参数。 doWork 检查并确定所有条件满足时,对 0 到 MaxVal 执行 filter。对于这样的场景,一般会选择基于任务的方式来实现,但是由于需要使用线程的 handle 设置任务的优先级,只能使用基于线程的方法来实现(see Item35)。可能的实现如下:

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

这里插一句题外话,在C++ 14中,tenMillion的初始值更加具有可读性:

constexpr auto tenMillion = 10'000'000;     // C++14

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

你可能会疑惑为什么 std::thread 的行为会是这样呢。那是因为如果选择下面两个选项,情况会更糟糕。这两个选项是:

  • 隐式 join。这种情况下,std::thread 的析构函数调用时,让其隐式的去调用 join,则会等待底层异步线程执行完成。这听起来很合理,但却会导致难以追踪的性能问题。而且,如果conditionsAreSatisfied() 已经返回 false,dowork 却还在执行,这简直就违反直觉了;
  • 隐式 detach。这种情况下,std::thread 在执行析构时,会分开 std::thread 和底层执行线程的连接。假设dowork率先返回了,dowork的栈帧也被弹出了。而线程还在执行,线程中可能还访问着dowork中的局部变量。我们假设dowork后面还有其他代码执行,比如有一个函数 ff 运行时,很有可能会使用dowork栈帧占用过的内存,但此时线程也在执行着。那么,从 f 视角看,自己栈帧上内存里的内容被莫名其妙的更改了。这就更可怕了。

标准委员会意识到,销毁一个 joinable状态的线程太可怕了,所以决定直接拒绝此种情况的发生(即,直接让程序终止运行)。。

如此一来,让 std::thread 对象结束时必须变成 unjoinable(不可联结)状态的责任就落到了程序员自己身上。想一想,我们代码中可能会有各种 return、continue、break、goto 等直接跳出作用域的情况,这该怎么办呢?

RAII 技术就可以解决上面的问题。RAII(Resource Acquisition Is Initialization,即,资源获取即初始化。其实这个技术的关键在于析构而非初始化)STL 的很多容器中都有应用,但是却没有与 std::thread 相对应的RAII 类。

所以,我们自己写一个,用户可以指定 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 前,也符合调用者的直觉。但是,std::thread成员变量的声明顺序是放在最后的;
  • ThreadRAII 提供了 get 函数,用以访问底层的 std::thread 对象。这和只能指针提供的 get 函数是一个意思(用以访问底层裸指针)。这是为了避免重复实现 std::thread 的所有接口;
  • ThreadRAII 的析构函数首先检查 t 是否是 joinable,这是有必要的。因为用户可能会通过 get 函数得到底层 std::thread 对象,并将其移动、join或detach;

将 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 39将会展示,使用 ThreadRAIIstd::thread 销毁上执行 join 有时不仅会导致性能异常,还会导致程序挂起问题。这类问题的“合适”解决方案是和异步执行的lambda表达式通信,当我们不需要它工作时,它应该提前返回,但C++ 11中不支持可中断线程。【Anthony Williams的《Concurrency in Action》9.2节可以找到一个漂亮的处理方法】

Item 17中解释过,如果一个类显示声明了析构函数,编译器就不会为它生成默认的移动操作了。这个例子中,ThreadRAII 支持可移动是合理的,所以我们显示提供移动支持:

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;
};

Things to Remember

  • 保证 std::thread 对象以unjoinable状态结束其生命周期;
  • 处在 joinable 状态的 std::thread 析构中如果调用 join 可能导致性能问题;
  • 处在 joinable 状态的 std::thread 析构中如果调用 detach 可能导致未定义行为;
  • 在数据成员列表的最后声明 std::thread 对象;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值