条款35:优于选用基于任务而非基于线程的程序设计

如果你想以异步方式运行函数doAsyncWork,有两种基本选择。你可以创建一个std::tread,并在其上运行doAsyncWork,因此这是基于线程的途径:

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

抑或,你可以把doAsyncWork传递给std::async,这种策略叫做基于任务(task-based):

auto fut = std::async(doAsyncWork); //fut是“期值”(future)的缩写

在这样的调用中,传递给std::async的函数对象(例如,doAsyncWork)被看作任务(task).

基于任务的方法通常比基于线程实现的对应版本要好,即使从刚才我们看过的这么几行代码中,已经展示了一些原因。请看,doAsyncWork会产生一个返回值,我们有理由假定,调用doAsyncWork的代码会对该值感兴趣。在基于线程的调换用中,没有什么直截了当的办法能够获取该值;而在基于任务的调用中,这很容易,因为std::async返回的期值提供了get函数。如果doAsyncWork函数发射了一个异常,get函数就更重要了,因为他能访问到该异常。而如果采用了基于线程的途径,在doAsyncWork抛出异常时,程序就会死翘翘(经由调用std::terminate)。

基于线程和基于任务的程序设计之间更基本的区别在于,基于任务的程序设计表现着更高阶的抽象。它把你从线程管理的细节中解放了出来,说到这里,我有必要概述一下“线程”在带有并发的C++软件中的三种意义:

  • 硬件线程是实际执行计算的线程。现代计算机体系结构会为每个CPU内核提供一个或多个硬件线程。
  • 软件线程(又称操作系统线程或系统线程)是操作系统用以实施跨进程的管理,以及进行硬件线程调度的线程。通常,能够创建的软件线程会比硬件线程要多,因为当一个软件线程阻塞了(例如,阻塞I/O操作上,或者需要等待互斥量或条件变量等),运行另外的非阻塞线程能够提升吞吐率。
  • std::thread是C++进程里的对象,用作底层软件线程的句柄。有些std::thread对象表示为"null"句柄,对应于“无软件线程”,可能的原因有:它们处于默认构造状态(因此没有待执行的函数),或者被移动了(作为移动目的的std::thread对象成为了底层线程的句柄),或者被联结了(待运行的函数已运行结束),或者被分离了(std::thread对象与其底层软件线程的连接被切断了)。

软件线程是一种有限的资源,如果你试图创建的线程数量多于系统能够提供的数量,就会抛出std::system_error异常。这一点无论如何都会成立,即使待运行函数不能抛出异常。例如,即使doAsyncWork带有noexcept声明饰词,

int doAsyncWork() noexcept;   //关于noexcept,参见条款14

这条语句还是可能会抛出异常:

std::thread t(doAsyncWork); //若已无可用线程,则抛出异常

 写的好的软件必须采取某种办法来处理这样的可能性,但如何解决呢?一个办法是在当前线程中运行doAsyncWork,但这会导致负载不均衡,而且,如果当前线程是个GUI线程,会导致不能响应。另一种方法是等待某些已存在的软件线程完成工作,然后再尝试创建一个新的std::thread对象,但是有可能发生这种事情:已存在的线程在等待应该由doAsyncWork执行的某个动作(例如,产生返回值,或者向条件变量发送通知等)。

即使没有用尽线程,还是会发生超订问题,也就是,就绪状态(即非阻塞)的软件线程超过了硬件线程数量的时候。这种情况发生以后,线程调度器(通常是操作系统的一部分)会为软件线程在硬件线程之上分配CPU时间片。当一个线程的时间片用完,另一个线程启动的时候,就会执行语境切换。这种语境切换会增加系统的总体线程管理开销,尤其在一个软件线程的这一次和下一次被调度器切换到不同的CPU内核上的硬件线程时会发生高昂的计算成本。在那种情况下,(1)那个软件线程通常不会命中CPU缓存(即,它们几乎不会包含对于那软件线程有用的任何数据和指令);(2)CPU内核运行的“新”软件线程还会“污染”CPU缓存上为“旧”线程所准备的数据,它们曾经在该CPU内核上运行过,并且很可能在此被调度到同一个内核运行。

避免超订是困难的,因为软件线程和硬件线程的最佳比例取决于软件线程变成可运行状态的频繁程度,而这是会动态地改变的,例如,当一个线程从I/O密集型转换为计算密集型的时候,软件线程和硬件线程的最佳比例也依赖于语境切换的成本,以及软件线程使用CPU缓存时的命中率。而硬件线程的数量和CPU缓存的细节(例如,缓存尺寸大小,以及相对速度)又取决于计算机体系结构,所以即使你在一个平台上调优好了你的应用,避免了超订(同时保持硬件满载工作),也无法保证在另一种机器上你的方案还是能高效工作。

如果把这些问题扔给别人去做,你的生活就可以轻松起来,而使用std::async正是做到了这一点:

auto fut = std::async(doAsyncWork); //由标准库的实现着负责线程管理

这句调用把线程管理的责任转交给了C++标准库的实现者。例如,收到线程耗尽异常的可能性会大幅度地减小,因为这句调用可能从不产生该异常。“这怎么可能呢?”你可能会好奇,“如果我申请的软件线程数量多余系统可以提供的,使用std::thread和使用std::async有什么关系呢?”还真的有关系,因为当这种形式(即默认启动策略,参见条款36)调用std::async时,系统不保证会创建一个新的软件线程。相反,它允许调度器把指定函数(本例中指doAsyncWork)运行在请求doAsyncWork结果的线程中(例如,对fut调用了get或者wait的线程),如果系统发生了超订或线程耗尽,合理的调度器就可以利用这个自由度。

如果你自己来玩这个“在需求函数结果的线程上运行”的把戏,我曾说明过这会导致负载失衡,这问题并不会消失,只是由std::async和运行时调度器来代替你来面对它们。当谈到负载均衡时,运行时调度器很可能对于当前机器上正在发生什么比你有个更加全面的了解,因为它管理的是所有进程的线程,而非只是运行着你的代码的那个。

即使使用std::async,GUI线程的响应性也会有问题,因为调度器没有办法知道哪个线程在响应性方面需求比较紧迫。在那种情况下,你可以把std::lanuch::async的启动策略传递给std::async。那样做可以保证你想要运行的函数确实会在另一个线程中执行。

最高水平的线程调度器会使用全系统范围的线程池来避免超订,而且还会通过运用工作窃取算法来提高硬件内核间的负载均衡。C++标准库并未要求一定使用线程池或者工作窃取算法,而且,实话实说,C++11并发规格的一些技术细节让我们不能像希望的程度那样去利用他们。但是,一些厂商还是会在它们的标准库实现中利用该技术,并且我们有理由期待这一领域会继续进步。如果你使用基于任务的方法进行编程,则在相关技术普及之时你会自动地享受到好处。如若不然,你直接使用了std::thread进行程序设计,则要自行承担处理线程的耗尽、超订和复杂均衡的重担,更不用提你在程序中的解决方案能否应用在同一台机器的另一个进程上,从而使问题变得雪上加霜了。

比起基于线程编程,基于任务的设计能够分担你手工管理线程的艰辛,而且它提供了一种很自然地方式,让你检查异步执行函数的结果(即,返回值或异常)。但是仍有几种情况下,直接使用线程会更适合,他们包括:

  • 你需要访问底层线程实现的API:C++并发API通常会采用特定平台的低级API来实现,经常使用的有pthread或Windows线程库。它们提供的API比C++提供的更丰富(例如,C++没有线程优先级和亲和性的概念)。为了访问底层线程实现的API,std::thread通常会提供native_handle成员函数,而std::future(即std::async的返回型别)则没有该功能的对应物。
  • 你需要且有能力为你的应用优化编程用法。这也是有可能的,例如,你开发的是个服务器软件,执行时的性能剖析情况已知,并且作为唯一的主要进程部署在一种硬件特性固定的机器平台上
  • 你需要实现超越C++并发API的线程技术。例如,在C++实现中未提供的线程池的平台上实现线程池。

无论如何,这些都不是常见的情况,大多数时候,你应该选择基于任务的设计,而非直接进行线程相关的程序设计。

要点速记

  • std::thread的API未提供直接获取异步运行函数返回值的途径,而且如果那些函数抛出异常,程序就会终止
  • 基于线程的程序设计要求手动管理线程耗尽、超订、负载均衡、以及新平台适配
  • 经由应用了默认启动策略的std::async进行基于任务的程序设计,大部分这类问题都能找到解决之道。

你可能感兴趣的:(Effective,Modern,C++,开发语言,c++)