这篇文章是关于std::thread的,但是和线程或者多线程概念无关,而且也不会介绍C++中的线程机制。我假设你们已经很熟悉标准库线程组件和异步式编程了。

我见过很多文章在介绍C++线程的时候,都会给一些类似下面这段代码的示例代码:

void run_in_parallel(function<void()> f1, function<void()> f2)
{
  thread thr{f1}; // run f1 in a new thread
  f2();           // run f2 in this thread
  thr.join();     // wait until f1 is done
}

当然了,这段代码会让你对线程的构造函数用法有一些直观的感觉,也会让你对线程的分离(fork)和合并(join)有所了解。但是,我感觉这段代码没有足够的强调它是有多么的异常-非安全,并且一般而言,这种直接使用裸线程的方式是非常不安全的。在这篇文章里,我会尽力去分析这里提到的“安全”问题。

如果一个异常被抛出了,我们该怎么做?这个问题是(或者至少应该是)C++的核心问题。在C++中,几乎每个函数都有可能抛出一个异常,代码应该尽量做到即使有可疑函数抛出了异常,也仍然能保持清晰,并且在可控的范围内。这里的“清晰”和“可疑函数”,意味着我们的代码必须形式上有异常安全机制保障(如果你想了解更多此类信息,请看 Dave Abrahams的文章中的例子)。一些人不喜欢异常处理机制,因为这被认为是多余的责任,你必须采用不同返回值或者类似方法来解决这个问题。但是异常在C++中却是真实存在的,而且几乎每个函数都有可能抛出异常,这一条定理对上面那段代码的f1函数和f2函数同样适用。

那么,如果f1函数在另外一个线程中抛出了一个异常,会发生什么事?我们并不常提及一个事实:每一个线程都有它自己的调用栈。从技术上来讲,C++标准并不要求有调用栈,它仅仅要求了栈展开,在这个“栈”里面,唯一需要“实体”的是异常处理器(catch字句)和自动对象的析构。如果宿主线程的栈被展开后,却没找到相匹配的异常处理器(这种情况就是异常离开了f1),函数std::terminate就会被调用,这和让一个异常在main函数之外发生非常相似。某种程度上,在我们的例子中,f1相对于它的宿主线程,相当于main函数和main线程的关系。

如果函数f2抛出了一个异常,会发生什么?栈展开会在主线程中开始,合并指令会被跳过;线程的析构函数会被调用。根据标准惯例,线程是可以合并的(它既没有被合并,也没有被拆开)。对一个可合并的线程调用析构函数会导致系统自动调用std::terminate。

如果你非常熟悉异常安全保障机制,你会注意到,我对一件事非常含糊不清。调用std::terminate不一定是异常非安全的,异常安全至少意味着不存在资源泄露,同时我们也不希望让程序处在一种不稳定的状态。std::terminate并不会使程序处于不稳定的状态:它根本就不会让程序继续运行。关于资源泄露的问题,终止程序也许可以处理一些资源的泄露,在程序被终止后,泄露的资源,例如自由存储区(译者注:这里指的就是堆区)会变的和程序毫无关系。但是,如果f1或者f2抛出了不太舒服的异常,系统就会调用std::terminate。

为什么有这些约束?

为什么这些在子线程中未处理的异常能引起调用std::terminate?其他的一些编程语言选择隐式的停止子线程,但是却让其他线程保持运行。在C++哲学里,没有异常可以被直接忽略,每个异常必须被显式的处理。如果我们不能用更好的方法去处理它,那么最后的方法则是调用std::terminate。同时也要注意:事实上,通过调用std::terminate,一个异常可以被处理的非常有意义。(看下面)。

为什么一个可合并线程的析构函数不得不调用std::terminate?毕竟,析构函数可以在子线程中执行,或者,它可以从子线程中分离出来,或者,他可以直接撤销线程。简而言之,你不可能加入到析构函数中,因为,如果f2抛出了异常,这有可能导致意想不到的程序无响应。

try {
  if_we_join::thread thr{f1};

  if (skip_f2()) {         // suppose this is true
    throw runtime_error();
  }

  f2();
}
catch (std::exception const&) {
  // will not get here soon: f1 is still running
}

你不能把它(子线程)抽离出来,因为这会使当前程序状况处在风险中。当主线程离开它的作用域后,子线程会被启动,然后子线程会一直保持运行,并且会引用到资源已经被主线程释放的作用域中。

try {
  Resource r{};            // a resource in local scope

  auto closure = [&] {     // the closure takes reference to 'r'
    do_somethig_for_1_minute();
    use(r);
  };

  if_we_detach::thread thr{closure};

  throw runtime_error();
}
catch (std::exception const&) {
  // at this time f1 is still using 'r' which is gone
}

关于不合并和不分离的理论说明细节可以在N2802文档上找到。为什么线程的析构函数不能够撤销子线程?按照POSIX线程标准(详细链接),撤销线程和C++资源管理手法并不能很好的兼容,原因则是:析构函数和RAII(资源获取就是初始化)。如果一个线程在自动对象析构函数未被调用的情况下就被撤销,或者至少,这些析构函数是否被调用取决于具体的实现,那么,这也许会导致过多的资源泄露。因此,对于C++来说,撤销线程是不可能的。但是,却有一个类似的机制来处理这些:线程中断。中断是非常友好的:将要被中断的线程必须通知中断将要发生。如果中断了,一个特殊的异常将会被抛出,直到这个异常被传递到了最外层作用域,子线程的栈才会被展开。然而,中断机制在C++11标准中也并不可用,人们曾经考虑过将此机制引入C++11标准,但是,最后却因为非常滑稽的原因被拒绝了,这个原因会在另外一篇文章里详述。

请注意,std::thread的析构函数调用std::terminate的问题并不真的和异常相关。当我们无意识的忘记调用(线程)合并或者分离的时候,它同样会被触发:

{
  thread thr{f1};
  // lots of code ...

  if (condition()) return;
  // lots of code ...

  thr.join();
  return;
}

如果我们有一个长函数,这个函数具有不同的返回值,当每次退出作用域的时候,我们有可能忘记去合并/分离(线程)。注意,这种情况和手动清理资源很相似,但是,我们有两种方法去清理资源:或者合并或者分离,程序不可能自动的去选择其中一种方式。

因此,在给出了这些和std::threads相关的安全问题后,这些东西又对什么是有利的呢?为了回答这个问题,我们不得不先离一下题。

底层的原始信息

下面这个例子经常用于工作面试(对于C++程序员):

// why is this unsafe?  
 Animal * animal = makeAnimal(params);  
use(animal);  
delete animal; 

事实上,在实际的代码中使用缺乏保护的delete操作符经常是编程错误,程序员们被告知尽量使用高级的工具,比如用std::unique_ptr去做这种工作。但是,鉴于在STL中已经有类似高级的工具了,我们还需要原始的delete吗?答案是“需要”:我们仍需要用它来构建类似于std::unique_ptr的高级别的工具。

这个答案和std:thread或多或少有些类似。这是一种系统底层的抽象化,为了和操作系统的线程建立1对1的映射关系。我们有一种标准的可移植的组件来扮演线程的角色,并且不会产生运行时期的开销,利用这种底层的工具,我们能够按照我们的需求,建立更多的高级别的,减少异常的工具。

如果你想你的线程类可以在析构函数里合并,你只需要按照RAII的样子来实现线程类,把std::thread在类的内部进行管理,下面这段代码就完全是你需要的:

{
  JoiningThread thr{f1}; // run f1 in a new thread
  f2();                  // run f2 in this thread
}                        // wait until f1 is done

你想要你的线程类在析构函数中被分离吗?我们可以写另外一个线程类。对于析构函数中每种可能的、有意义的行为,你想不想添加一个新的类?请让你的类型是可配置的:

{
  SafeThread<Join> thr{f1};
  f2();                  
}

或者

{
  SafeThread thr{f1, join};
  f2();                  
}

这个方案正是boost::thread所提出的(详细)。注意,如果你正在用boost::thread,在线程封装器中,一个合理的、可选的行为是第一次中断子线程后,然后和子线程合并。同理,删除操作也是类似的,对于程序员来说,一个合理的建议是,一直使用类似的封装器来完成这个任务。

高级的多线程抽象

因此,标准库为多线程提供了什么高级别的抽象?实际上,并没有。有一个:函数std::async。它用来解决一个问题:在一个新线程里安全的开始一个任务。当异常在子线程中被抛出的时候(我们的f1函数),它能干净的处理类似问题;当你忘记去合并/分离(隐式的合并)的时候,它并不会终止(线程)。在这个文章里,我们没有时间去对这个做更详细的分析。

然而,如果你对它期盼过多的话,你会很惊艳的。例如,我们上面的例子也可以这么写:

{
  JoiningThread thr{f1}; // run f1 in a new thread
  f2();                  // run f2 in this thread
}                        // wait until f1 is done

你也许会很惊讶的发现,f1和f2并不会并行化执行:直到f1终止前,f2才会执行!你可以读一下Herb Sutter的这篇论文来获得更多的解释。另外,Bartosz Milewski也描述了一些关于async的无效期望的事情,在他的文章Async Tasks in C++11: Not Quite There Yet中,有详细的描述.

那么线程池呢?还有非阻塞式并发队列?“并行算法”?并不在C++11标准里,为什么?答案是非常简短的。C++委员会并没有时间去做这些东西的标准化。在这一点上,很多人经常表达一种沮丧或者失望的情绪。我并没发现以上的事情多有趣、多有成效,甚至于多合理。C++11标准为并发机制提供了一个非常牢固的基础:内存模型、原子操作,和线程的概念以及线程锁。如果我的文章让你感觉std::thread大体上是没用的,这并不是事实:他和其他的底层基本工具一样有用。它给了你潜在的空间去构建各种各样的高级多线程工具,我的目的仅仅是展示一下如果小心的使用它。用一句话总结就是“不要用在程序里用裸线程:用类似RAII封装器去代替它”。

在下一个C++11标准的版本修订中,很可能会提供很多的并发和并行计算抽象内容。如果你在委员会的邮件列表里看过今年的提案,你会发现很多的主题是关于这个的。

本文翻译自Andrzej的C++博客,原文:http://akrzemi1.wordpress.com/2012/11/14/not-using-stdthread/