第7章 并发API
CHAPTER 7 The Concurrency API
C++11的伟大成功之一是将并发整合到语言和库中。熟悉其他线程API(比如pthreads或者Windows threads)的开发者有时可能会对C++提供的斯巴达式(译者注:应该是简陋和严谨的意思)功能集感到惊讶,这是因为C++对于并发的大量支持是在对编译器作者约束的层面。由此产生的语言保证意味着在C++的历史中,开发者首次通过标准库可以写出跨平台的多线程程序。这为构建表达库奠定了坚实的基础,标准库并发组件(任务tasks,期望futures,线程threads,互斥mutexes,条件变量condition variables,原子对象atomic objects等)仅仅是成为并发软件开发者丰富工具集的基础。
在接下来的条款中,记住标准库有两个future的模板:std::future
和std::shared_future
。在许多情况下,区别不重要,所以我们经常简单的混于一谈为futures。
条款三十五:优先考虑基于任务的编程而非基于线程的编程
Item 35: Prefer task-based programming to thread-based
如果开发者想要异步执行doAsyncWork
函数,通常有两种方式。其一是通过创建std::thread
执行doAsyncWork
,这是应用了基于线程(thread-based)的方式:
int doAsyncWork();
std::thread t(doAsyncWork);
其二是将doAsyncWork
传递给std::async
,一种基于任务(task-based)的策略:
auto fut = std::async(doAsyncWork); //“fut”表示“future”
这种方式中,传递给std::async
的函数对象被称为一个任务(task)。
基于任务的方法通常比基于线程的方法更优,原因之一上面的代码已经表明,基于任务的方法代码量更少。我们假设调用doAsyncWork
的代码对于其提供的返回值是有需求的。基于线程的方法对此无能为力,而基于任务的方法就简单了,因为std::async
返回的future提供了get
函数(从而可以获取返回值)。如果doAsycnWork
发生了异常,get
函数就显得更为重要,因为get
函数可以提供抛出异常的访问,而基于线程的方法,如果doAsyncWork
抛出了异常,程序会直接终止(通过调用std::terminate
)。
基于线程与基于任务最根本的区别在于,基于任务的抽象层次更高。基于任务的方式使得开发者从线程管理的细节中解放出来,对此在C++并发软件中总结了“thread”的三种含义:
- 硬件线程(hardware threads)是真实执行计算的线程。现代计算机体系结构为每个CPU核心提供一个或者多个硬件线程。
- 软件线程(software threads)(也被称为系统线程(OS threads、system threads))是操作系统(假设有一个操作系统。有些嵌入式系统没有。)管理的在硬件线程上执行的线程。通常可以存在比硬件线程更多数量的软件线程,因为当软件线程被阻塞的时候(比如 I/O、同步锁或者条件变量),操作系统可以调度其他未阻塞的软件线程执行提供吞吐量。
std::thread
是C++执行过程的对象,并作为软件线程的句柄(handle)。有些std::thread
对象代表“空”句柄,即没有对应软件线程,因为它们处在默认构造状态(即没有函数要执行);有些被移动走(移动到的std::thread
就作为这个软件线程的句柄);有些被join
(它们要运行的函数已经运行完);有些被detach
(它们和对应的软件线程之间的连接关系被打断)。
软件线程是有限的资源。如果开发者试图创建大于系统支持的线程数量,会抛出std::system_error
异常。即使你编写了不抛出异常的代码,这仍然会发生,比如下面的代码,即使 doAsyncWork
是 noexcept
,
int doAsyncWork() noexcept; //noexcept见条款14
这段代码仍然会抛出异常:
std::thread t(doAsyncWork); //如果没有更多线程可用,则抛出异常
设计良好的软件必须能有效地处理这种可能性,但是怎样做?一种方法是在当前线程执行doAsyncWork
,但是这可能会导致负载不均,而且如果当前线程是GUI线程,可能会导致响应时间过长的问题。另一种方法是等待某些当前运行的软件线程结束之后再创建新的std::thread
,但是仍然有可能当前运行的线程在等待doAsyncWork
的动作(例如产生一个结果或者报告一个条件变量)。
即使没有超出软件线程的限额,仍然可能会遇到资源超额(oversubscription)的麻烦。这是一种当前准备运行的(即未阻塞的)软件线程大于硬件线程的数量的情况。情况发生时,线程调度器(操作系统的典型部分)会将软件线程时间切片,分配到硬件上。当一个软件线程的时间片执行结束,会让给另一个软件线程,此时发生上下文切换。软件线程的上下文切换会增加系统的软件线程管理开销,当软件线程安排到与上次时间片运行时不同的硬件线程上,这个开销会更高。这种情况下,(1)CPU缓存对这个软件线程很冷淡(即几乎没有什么数据,也没有有用的操作指南);(2)“新”软件线程的缓存数据会“污染”“旧”线程的数据,旧线程之前运行在这个核心上,而且还有可能再次在这里运行。
避免资源超额很困难,因为软件线程之于硬件线程的最佳比例取决于软件线程的执行频率,那是动态改变的,比如一个程序从IO密集型变成计算密集型,执行频率是会改变的。而且比例还依赖上下文切换的开销以及软件线程对于CPU缓存的使用效率。此外,硬件线程的数量和CPU缓存的细节(比如缓存多大,相应速度多少)取决于机器的体系结构,即使经过调校,在某一种机器平台避免了资源超额(而仍然保持硬件的繁忙状态),换一个其他类型的机器这个调校并不能提供较好效果的保证。
如果你把这些问题推给另一个人做,你就会变得很轻松,而使用std::async
就做了这件事:
auto fut = std::async(doAsyncWork); //线程管理责任交给了标准库的开发者
这种调用方式将线程管理的职责转交给C++标准库的开发者。举个例子,这种调用方式会减少抛出资源超额异常的可能性,因为这个调用可能不会开启一个新的线程。你会想:“怎么可能?如果我要求比系统可以提供的更多的软件线程,创建std::thread
和调用std::async
为什么会有区别?”确实有区别,因为以这种形式调用(即使用默认启动策略——见Item36)时,std::async
不保证会创建新的软件线程。然而,他们允许通过调度器来将特定函数(本例中为doAsyncWork
)运行在等待此函数结果的线程上(即在对fut
调用get
或者wait
的线程上),合理的调度器在系统资源超额或者线程耗尽时就会利用这个自由度。
如果考虑自己实现“在等待结果的线程上运行输出结果的函数”,之前提到了可能引出负载不均衡的问题,这问题不那么容易解决,因为应该是std::async
和运行时的调度程序来解决这个问题而不是你。遇到负载不均衡问题时,对机器内发生的事情,运行时调度程序比你有更全面的了解,因为它管理的是所有执行过程,而不仅仅个别开发者运行的代码。
有了std::async
,GUI线程中响应变慢仍然是个问题,因为调度器并不知道你的哪个线程有高响应要求。这种情况下,你会想通过向std::async
传递std::launch::async
启动策略来保证想运行函数在不同的线程上执行(见Item36)。
最前沿的线程调度器使用系统级线程池(thread pool)来避免资源超额的问题,并且通过工作窃取算法(work-stealing algorithm)来提升了跨硬件核心的负载均衡。C++标准实际上并不要求使用线程池或者工作窃取,实际上C++11并发规范的某些技术层面使得实现这些技术的难度可能比想象中更有挑战。不过,库开发者在标准库实现中采用了这些技术,也有理由期待这个领域会有更多进展。如果你当前的并发编程采用基于任务的方式,在这些技术发展中你会持续获得回报。相反如果你直接使用std::thread
编程,处理线程耗尽、资源超额、负载均衡问题的责任就压在了你身上,更不用说你对这些问题的解决方法与同机器上其他程序采用的解决方案配合得好不好了。
对比基于线程的编程方式,基于任务的设计为开发者避免了手动线程管理的痛苦,并且自然提供了一种获取异步执行程序的结果(即返回值或者异常)的方式。当然,仍然存在一些场景直接使用std::thread
会更有优势:
- 你需要访问非常基础的线程API。C++并发API通常是通过操作系统提供的系统级API(pthreads或者Windows threads)来实现的,系统级API通常会提供更加灵活的操作方式(举个例子,C++没有线程优先级和亲和性的概念)。为了提供对底层系统级线程API的访问,
std::thread
对象提供了native_handle
的成员函数,而std::future
(即std::async
返回的东西)没有这种能力。 - 你需要且能够优化应用的线程使用。举个例子,你要开发一款已知执行概况的服务器软件,部署在有固定硬件特性的机器上,作为唯一的关键进程。
- 你需要实现C++并发API之外的线程技术,比如,C++实现中未支持的平台的线程池。
这些都是在应用开发中并不常见的例子,大多数情况,开发者应该优先采用基于任务的编程方式。
请记住:
std::thread
API不能直接访问异步执行的结果,如果执行函数有异常抛出,代码会终止执行。- 基于线程的编程方式需要手动的线程耗尽、资源超额、负责均衡、平台适配性管理。
- 通过带有默认启动策略的
std::async
进行基于任务的编程方式会解决大部分问题。