前言

C++11的伟⼤标志之⼀是将并发整合到语⾔和库中。熟悉其他线程API(⽐如pthreads或者 Windows threads)的开发者有时可能会对 C++ 提供的简陋和严谨的功能集感到惊讶,这是因为 C++ 对于并发的⼤量⽀持是在编译器的约束层⾯。由此产⽣的语⾔保证意味着在 C++ 的历史中,开发者⾸次通过标准库可以写出跨平台的多线程程序。这位构建表达库奠定了坚实的基础,并发标准库(tasks, futures, threads, mutexes, condition variables, atomic objects等)仅仅是成为并发软件开发者丰富⼯具集的基础。

在接下来的 Item 中,记住标准库有两个 futures 的模板:std::futurestd::shared_future。在许多情况下,区别不重要,所以我们经常简单的混于⼀谈为 futures

优先基于任务编程而不是基于线程

如果开发者想要异步执⾏ doAsyncWork 函数,通常有两种⽅式。其⼀是通过创建 std::thread 执⾏ doAsyncWork, ⽐如

1
2
int doAsyncWork();
std::thread t(doAsyncWork);

其⼆是将 doAsyncWork 传递给 std::async, ⼀种基于任务的策略:

1
auto fut = std::async(doAsyncWork); // "fut" for "future"

这种⽅式中,函数对象作为⼀个任务传递给 std::async

基于任务的⽅法通常⽐基于线程的⽅法更优,原因之⼀上⾯的代码已经表明,基于任务的⽅法代码量更少。我们假设唤醒 doAsyncWork 的代码对于其提供的返回值是有需求的。基于线程的⽅法对此⽆能为⼒,而基于任务的⽅法可以简单地获取 std::async 返回的 future 提供的 get 函数获取这个返回值。如果 doAsycnWork 发⽣了异常,get 函数就显得更为重要,因为 get 函数可以提供抛出异常的访问,而基于线程的⽅法,如果 doAsyncWork 抛出了异常,线程会直接终⽌(通过调⽤ std::terminate)。

基于线程与基于任务最根本的区别在于抽象层次的⾼低。基于任务的⽅式使得开发者从线程管理的细节中解放出来,对此在C++并发软件中总结了 thread 的三种含义:

  • 硬件线程(Hardware threads)是真实执⾏计算的线程。现代计算机体系结构为每个 CPU 核⼼提供⼀个或者多个硬件线程。

  • 软件线程(Software threads)(也被称为系统线程)是操作系统管理的在硬件线程上执⾏的线程。通常可以存在⽐硬件线程更多数量的软件线程,因为当软件线程被⽐如 I/O、同步锁或者条件变量阻塞的时候,操作系统可以调度其他未阻塞的软件线程执⾏提供吞吐量。

  • std::threads 是C++执⾏过程的对象,并作为软件线程的handle(句柄)。std::threads 存在多种状态,1.null 表⽰空句柄,因为处于默认构造状态(即没有函数来执⾏),因此不对应任何软件线程。 2.moved from (moved-to的 std::thread 就对应软件进程开始执⾏) 3. joined (连接唤醒与被唤醒的两个线程) 4.detached(将两个连接的线程分离)

软件线程是有限的资源。如果开发者试图创建⼤于系统⽀持的硬件线程数量,会抛出
std::system_error 异常。即使你编写了不抛出异常的代码,这仍然会发⽣,⽐如下⾯的代码,即使 doAsyncWorknoexcept

1
int doAsyncWork() noexcept; // see Item 14 for noexcept

这段代码仍然会抛出异常。

1
2
std::thread t(doAsyncWork); // throw if no more
// threads are available

设计良好的软件必须有效地处理这种可能性(软件线程资源耗尽),⼀种有效的⽅法是在当前线程执⾏ doAsyncWork,但是这可能会导致负载不均,而且如果当前线程是 GUI 线程,可能会导致响应时间过⻓的问题;另⼀种⽅法是等待当前运⾏的线程结束之后再创建新的线程,但是仍然有可能当前运⾏的线程在等待 doAsyncWork 的结果(例如操作得到的变量或者条件变量的通知)。

即使没有超出软件线程的限额,仍然可能会遇到资源超额的⿇烦。如果当前准备运⾏的软件线程⼤于硬件线程的数量,系统的线程调度程序会将硬件核⼼的时间切⽚,当⼀个软件线程的时间⽚执⾏结束,会让给另⼀个软件线程,即发⽣上下⽂切换。软件线程的上下⽂切换会增加系统的软件线程管理开销,并且如果发⽣了硬件核⼼漂移,这个开销会更⾼,具体来说,如果发⽣了硬件核⼼漂移,(1)CPU cache 中关于上次执⾏线程的数据很少,需要重新加载指令;(2)新线程的cache数据会覆盖⽼线程的数据,如果将来会再次覆盖⽼线程的数据,显然频繁覆盖增加很多切换开销。

避免资源超额是困难的,因为软件线程之于硬件线程的最佳⽐例取决于软件线程的执⾏频率,(⽐如⼀个程序从 IO 密集型变成计算密集型,执⾏频率是会改变的),而且⽐例还依赖上下⽂切换的开销以及软件线程对于 CPU cache 的使⽤效率。此外,硬件线程的数量和 CPU cache 的速度取决于机器的体系结构,即使经过调校,软件⽐例在某⼀种机器平台取得较好效果,换⼀个其他类型的机器这个调校并不能提供较好效果的保证。

而使⽤ std::async 可以将调校最优⽐例这件事隐藏于标准库中,在应⽤层⾯不需过多考虑

1
2
3
4
5
auto fut = std::async(doAsyncWork); // onus of thread mgmt is
// on
implement of
// the
Standard Library

这种调⽤⽅式将线程管理的职责转交给 C++ 标准库的开发者。举个例⼦,这种调⽤⽅式会减少抛出资源超额的异常,为何这么说调⽤ std::async 并不保证开启⼀个新的线程,只是提供了执⾏函数的保证,具体是否创建新的线程来运⾏此函数,取决于具体实现,⽐如可以通过调度程序来将 AsyncWork 运⾏在等待此函数结果的线程上,调度程序的合理性决定了系统是否会抛出资源超额的异常,但是这是库开发者需要考虑的事情了。

如果考虑⾃⼰实现在等待结果的线程上运⾏输出结果的函数,之前提到了可能引出负载不均衡的问题,std::async 运⾏时的调度程序显然⽐开发者更清楚调度策略的制定,因为运⾏时调度程序管理的是所有执⾏过程,而不仅仅个别开发者运⾏的代码。

如果在GUI程序中使⽤ std::async 会引起响应变慢的问题,还可以通过 std::launch::asyncstd::async 传递调度策略来保证运⾏函数在不同的线程上执⾏。

最前沿的线程调度算法使⽤线程池来避免资源超额的问题,并且通过窃取算法来提升了跨硬件核⼼的负载均衡。C++标准实际上并不要求使⽤线程池或者 work-stealing 算法,而且这些技术的实现难度可能⽐你想象中更有挑战。不过,库开发者在标准库实现中采⽤了这些前沿的技术,这使得采⽤基于任务的⽅式编程的开发者在这些技术发展中持续获得回报,相反如果开发者直接使⽤ std::thread 编程,处理资源耗竭,负责均衡问题的责任就压在了应⽤开发者⾝上,更不说如何使得开发⽅案跨平台使⽤。

对⽐基于线程的开发⽅式,基于任务的设计为开发者避免了线程管理的痛苦,并且⾃然提供了⼀种获取异步执⾏的结果的⽅式。当然,仍然存在⼀些场景直接使⽤ std::thread 会更有优势:

  • 需要访问⾮常基础的线程 API。C++ 并发 API 通常是通过操作系统提供的系统级API(pthreads 或者 windows threads)来实现的,系统级API通常会提供更加灵活的操作⽅式,举个例⼦,C++并发 API 没有线程优先级和 affinities 的概念。为了提供对底层系统级线程 API 的访问,std::thread 对象提供了 native_handle 的成员函数,而在⾼层抽象的⽐如 std::futures 没有这种能⼒。

  • 需要优化应⽤的线程使⽤。举个例⼦,只在特定系统平台运⾏的软件,可以调教地⽐使⽤C++并⾏ API 更好的程序性能。

  • 需要实现C++并发API之外的线程技术。举例来说,⾃⾏实现线程池技术。

这些都是在应⽤开发中并不常⻅的例⼦,⼤多数情况,开发者应该优先采⽤基于任务的编程⽅式。

总结

  • std::thread API 不能直接访问异步执⾏的结果,如果执⾏函数有异常抛出,代码会终⽌执⾏
  • 基于线程的编程⽅式关于解决资源超限,负载均衡的⽅案移植性不佳
  • 基于任务的编程⽅式 std::async 会默认解决上⾯两条问题