EffectiveModernCppChinese/7.TheConcurrencyAPI/item37.md
David f65547e3a5
修正翻译错误 (#73)
* fix typo; add xmind

* 修正翻译错误

Co-authored-by: wendajiang <wendajiang93@163.com>
2021-01-28 00:31:57 +08:00

11 KiB
Raw Blame History

Item 37Make std::threads unjoinable on all paths

每个std::thread对象处于两个状态之一:joinable or unjoinablejoinable状态的std::thread对应于正在运行或者可能正在运行的异步执行线程。比如一个blocked或者等待调度的std::threadjoinable,已运行结束的std::thread也可以认为是joinable

unjoinablestd::thread对象比如:

  • Default-constructed std::threads。这种std::thread没有函数执行,因此无法绑定到具体的线程上
  • 已经被moved的std::thread对象。move的结果就是将std::thread对应的线程所有权转移给另一个std::thread
  • 已经joined的std::thread。在join之后std::thread执行结束,不再对应于具体的线程
  • 已经detached的std::thread。detach断开了std::thread与线程之间的连接

(译者注:std::thread可以视作状态保存的对象,保存的状态可能也包括可调用对象,有没有具体的线程承载就是有没有连接)

std::thread的可连接性如此重要的原因之一就是当连接状态的析构函数被调用,执行逻辑被终止。比如,假定有一个函数doWork,执行过滤函数filter,接收一个参数maxValdoWork检查是否满足计算所需的条件然后通过使用0到maxVal之间的所有值过滤计算。如果进行过滤非常耗时并且确定doWork条件是否满足也很耗时则将两件事并发计算是很合理的。

我们希望为此采用基于任务的设计参与Item 35但是假设我们希望设置做过滤线程的优先级。Item 35阐释了需要线程的基本句柄只能通过std::thread的API来完成基于任务的API比如futures做不到。所以最终采用基于std::thread而不是基于任务

代码如下:

constexpr auto tenMillion = 10000000; // see Item 15 for constexpr
bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion) // return whether computation was performed; see Item2 for std::function
{
  std::vector<int> goodVals;
  std::thread t([&filter, maxVal, &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 (conditionsAreStatisfied()) {
    t.join(); // let t finish 
    performComputation(goodVals); // computation was performed
    return true;
  }
  
  return false; // computation was not performed
}

在解释这份代码为什么有问题之前看一下tenMillion的初始化可以在C++14中更加易读通过单引号分隔数字

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

还要指出在开始运行之后设置t的优先级就像把马放出去之后再关上马厩门一样译者注太晚了。更好的设计是在t为挂起状态时设置优先级这样可以在执行任何计算前调整优先级但是我不想你为这份代码考虑这个而分心。如果你感兴趣代码中忽略的部分可以转到Item 39那个Item告诉你如何以挂起状态开始线程。

返回doWork。如果conditionsAreSatisfied()返回真,没什么问题,但是如果返回假或者抛出异常,std::thread类型的tdoWork结束时会调用t的析构器。这造成程序执行中止。

你可能会想,为什么std::thread析构的行为是这样的,那是因为另外两种显而易见的方式更糟:

  • 隐式join。这种情况下,std::thread的析构函数将等待其底层的异步执行线程完成。这听起来是合理的,但是可能会导致表现异常,而且难以追踪。比如,如果conditonAreStatisfied()已经返回了假,doWork继续等待过滤器应用于所有值就很违反直觉。

  • 隐式detach。这种情况下,std::thread析构函数会分离其底层的线程。线程继续运行。听起来比join的方式好但是可能导致更严重的调试问题。比如doWork中,goodVals是通过引用捕获的局部变量。可能会被lambda修改。假定lambda的执行时异步的conditionsAreStatisfied()返回假。这时,doWork返回,同时局部变量goodVals被销毁。堆栈被弹出,并在doWork的调用点继续执行线程

    某个调用点之后的语句有时会进行其他函数调用,并且至少一个这样的调用可能会占用曾经被doWork使用的堆栈位置。我们称为f,当f运行时,doWork启动的lambda仍在继续运行。该lambda可以在堆栈内存中调用push_back,该内存曾是goodVals,位于doWork曾经的堆栈位置。这意味着对f来说,内存被修改了,想象一下调试的时候痛苦

标准委员会认为,销毁连接中的线程如此可怕以至于实际上禁止了它(通过指定销毁连接中的线程导致程序终止)

这使你有责任确保使用std::thread对象时在所有的路径上最终都是unjoinable的。但是覆盖每条路径可能很复杂可能包括return, continue, break, goto or exception,有太多可能的路径。

每当你想每条路径的块之外执行某种操作最通用的方式就是将该操作放入本地对象的析构函数中。这些对象称为RAII对象通过RAII类来实例化。RAII全称为 Resource Acquisition Is Initialization。RAII类在标准库中很常见。比如STL容器智能指针std::fstream类等。但是标准库没有RAII的std::thread类,可能是因为标准委员会拒绝将join和detach作为默认选项不知道应该怎么样完成RAII。

幸运的是,完成自行实现的类并不难。比如,下面的类实现允许调用者指定析构函数join或者detach

class ThreadRAII {
public:
  enum class DtorAction{ join, detach }; // see Item 10 for enum class info
  ThreadRAII(std::thread&& t, DtorAction a): action(a), t(std::move(t)) {} // in dtor, take action a on t
  ~ThreadRAII() 
  {
    if (t.joinable()) {
      if (action == DtorAction::join) {
        t.join();
      } else {
        t.detach();
      }
    }
  }
  std::thread& get() { return t; } // see below
private:
  DtorAction action;
  std::thread t;
};

我希望这段代码是不言自明的,但是下面几点说明可能会有所帮助:

  • 构造器只接受std::thread右值因为我们想要move std::thread对象给ThreadRAII(再次强调,std::thread不可以复制)

  • 构造器的参数顺序设计的符合调用者直觉(首先传递std::thread,然后选择析构执行的动作),但是成员初始化列表设计的匹配成员声明的顺序。将std::thread成员放在声明最后。在这个类中,这个顺序没什么特别之处,调整为其他顺序也没有问题,但是通常,可能一个成员的初始化依赖于另一个,因为std::thread对象可能会在初始化结束后就立即执行了,所以在最后声明是一个好习惯。这样就能保证一旦构造结束,所有数据成员都初始化完毕可以安全的异步绑定线程执行

  • ThreadRAII提供了get函数访问内部的std::thread对象。这类似于标准智能指针提供的get函数,可以提供访问原始指针的入口。提供get函数避免了ThreadRAII复制完整std::thread接口的需要,因为着ThreadRAII可以在需要std::thread上下文的环境中使用

  • ThreadRAII析构函数调用std::thread对象t的成员函数之前检查t是否joinable。这是必须的因为在unjoinbale的std::thread上调用join or detach会导致未定义行为。客户端可能会构造一个std::thread t然后通过t构造一个ThreadRAII,使用get获取t然后移动t或者调用join or detach每一个操作都使得t变为unjoinable

    如果你担心下面这段代码

    if (t.joinable()) {
      if (action == DtorAction::join) {
        t.join();
      } else {
        t.detach();
      }
    }
    

    存在竞争,因为在t.joinable()t.join or t.detach执行中间可能有其他线程改变了t为unjoinable你的态度很好但是这个担心不必要。std::thread只有自己可以改变joinable or unjoinable的状态。在ThreadRAII的析构函数中被调用时其他线程不可能做成员函数的调用。如果同时进行调用那肯定是有竞争的但是不在析构函数中是在客户端代码中试图同时在一个对象上调用两个成员函数析构函数和其他函数。通常仅当所有都为const成员函数时在一个对象同时调用两个成员函数才是安全的。

doWork的例子上使用ThreadRAII的代码如下:

bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion)
{
  std::vector<int> goodVals;
  ThreadRAII t(std::thread([&filter, maxVal, &goodVals] {
      for (auto i = 0; i <= maxVal; ++i) {
        if (filter(i)) goodVals.push_back(i);
      }
    }),
    ThreadRAII::DtorAction::join
  );
  auto nh = t.get().native_handle();
  ...
  if (conditonsAreStatisfied()) {
    t.get().join();
    performComputation(goodVals);
    return true;
  }
  return false;
}

这份代码中,我们通过ThreadRAII的析构函数对异步执行的线程进行join,因为在先前分析中,detach可能导致非常难缠的bug。我们之前也分析了join可能会导致表现异常(坦率说,也可能调试困难),但是在未定义行为(detach导致),程序终止(std::thread默认导致),或者表现异常之间选择一个后果,可能表现异常是最好的那个。

Item 39表明了使用ThreadRAII来保证在std::thread的析构时执行join有时可能不仅导致程序表现异常还可能导致程序挂起。“适当”的解决方案是此类程序应该和异步执行的lambda通信告诉它不需要执行了可以直接返回但是C++11中不支持可中断线程。可以自行实现但是这不是本书讨论的主题。译者注关于这一点C++ Concurrency in Action 的section 9.2 中有详细讨论,也有中文版出版)

Item 17说明因为ThreadRAII声明了一个析构函数,因此不会有编译器生成移动操作,但是没有理由ThreadRAII对象不能移动。所以需要我们显式声明来告诉编译器自动生成:

class ThreadRAII {
public:
  enum class DtorAction{ join, detach }; // see Item 10 for enum class info
  ThreadRAII(std::thread&& t, DtorAction a): action(a), t(std::move(t)) {} // in dtor, take action a on t
  ~ThreadRAII() 
  {
    if (t.joinable()) {
      if (action == DtorAction::join) {
        t.join();
      } else {
        t.detach();
      }
    }
  }
  
  ThreadRAII(ThreadRAII&&) = default;
  ThreadRAII& operator=(ThreadRAII&&) = default;
  std::thread& get() { return t; } // see below
private:
  DtorAction action;
  std::thread t;
};

需要记住的事

  • 在所有路径上保证thread最终是unjoinable
  • 析构时join会导致难以调试的表现异常问题
  • 析构时detach会导致难以调试的未定义行为
  • 声明类数据成员时,最后声明std::thread类型成员