每个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与执行线程之间的连接关系被切断。
例如,假设我们有一个函数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
};
constexpr auto tenMillion = 10'000'000; // C++ 14 only
你也许会问,为什么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的角度看,它的栈空间上的内存会自发的修改!想想你调试这个问题的乐趣吧。
无论你什么时候想在所有出作用域的地方做某件事,最常见的方法就是将这件事放入这个局部对象的析构函数中。这些对象称之为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)();
我们这里定义的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;
}
哎,可惜正如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;
};
重点回顾
- 保证std::thread在所有路径上都是unjoinable
- 在析构函数中join线程,会导致性能异常
- 在析构函数中detach线程,会导致未定义行为