Effective Modern C++ 条款35 比起基于线程编程,更偏爱基于任务编程

比起基于线程编程,更偏爱基于任务编程

如果你想异步地运行函数doAsyncWork,你有两个基本的选择。你可以创建一个std::thread,用它来运行doAsyncWork,因此这是基于线程(thread-based)的方法:

int doAsyncWork();

std::thread t(doAsyncWork);

或者你把doAsynWork传递给std::async,一个叫做基于任务(task-based)的策略:

auto fut = std::async(doAsyncWork);    // "fut"的意思是"future"

在这种调用中,传递给std::async的函数对象被认为是一个任务(task)。

基于任务的方法通常比基于线程实现的相对物要好,我们看到基于任务的方法代码量更少,这已经是展示了一些原因了。在这里,doAsyncWork会返回一个值,我们有理由假定调用doAsyncWork的代码对这个返回值有兴趣。在基于线程的调用中,没有直接的办法获取到它;而在基于任务的调用中,这很容易,因为std::asyn返回的future提供了一个函数get来获取返回值。如果doAsyncWork函数发出了一个异常,get函数是很重要的,它能取到这个异常。在基于线程的方法中,如果doAsyncWork抛出了异常,程序就死亡了(借助**std::terminate)。

基于线程编程和基于任务编程的一个更基础的区别是,基于任务编程表现出更高级别的抽象。它让你免受线程管理的细节,这让我想起了我需要总结“线程”在C++并发软件里的三个意思:

  • 硬件线程是一种负责计算的线程。现代机器体系结构为每个CPU核心提供一个或多个硬件线程。
  • 软件线程(又称为操作系统线程或系统线程)是由操作系统管理和为硬件线程进行调度的线程。软件线程创建的数量通常会比硬件线程多,因为当一个软件线程阻塞了(例如,I/O操作,等待锁或者条件变量),运行另一个非阻塞的线程能提供吞吐率。
  • std::thread是C++进程里的对象,它在自身内部操作软件线程。一些std::thread对象表示为“null”句柄,相当于不持有软件线程,因为它们处于默认构造状态(因此没有需要执行的函数),它要么被移动过了(那么,移动操作的目的std::thread对象会操作软件线程),要么被join了(std::thread对象要执行的函数运行结束),要么被detach了(std::thread对象和它内部软件线程的连接被切断了,即thread对象和软件线程分离了)。

软件线程是一种受限的资源,如果你想创建的线程数量多于系统提供的数量,会抛出std::system_error异常。就算你规定函数不能抛出异常,这个异常也会抛出。例如,就算你把doAsyncWork声明为noexcept

int doAsyncWork noexcept;   // 关于noexcept,请看条款14

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

std::thread t(doAsyncWork);  // 如果没有可获取的系统,就抛出异常

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

即使你的没有用完线程,你还是会有oversubscription(过载)的问题——当就绪状态(即非阻塞)的软件线程多于硬件线程的时候。如果发生了那种事,调度线程(通常是操作系统的一部分)会为软件线程分配CPU时间片,一个线程的时间片用完,就运行另一个线程,这其中发生了上下文切换。这种上下文切换会增加系统的线程管理开销。这种情况下,(1)CPU缓存会持有那个软件线程(即,它们会含有对于那软件线程有用的一些数据和一些指令),而(2)CPU核心上“新”运行的软件线程“污染”了CPU缓存上“旧的”线程数据(它曾经在该CPU核心运行过,且可能再次调度到该CPU核心运行)。

避免oversubscription是很困难的,因为软件系统和硬件线程的最佳比例是取决于软件线程多久需要执行一次,而这是会动态改变的,例如,当一个线程从IO消耗型转换为CPU消耗型时。这最佳比例也取决于上下文切换的开销和软件线程使用CPU缓存的效率。再进一步说,硬件线程的数量和CPU缓存的细节(例如,缓存多大和多快)是取决于机器的体系结构,所以即使你在一个平台上让你的应用避免了oversubscription(保持硬件繁忙工作),也不能保证在另一种机器上你的方案能工作得好。

如果你把这些问题扔给某个人去做,你的生活就很惬意啦,然后使用std::async就能显式地做这件事:

auto fut = std::async(doAsyncWork);  // 线程管理的责任交给标准库的实现者

这个调用把线程管理的责任转交给C++标准库的实现者。例如,得到线程超标的异常的可能性绝大幅度减少,因为这个调用可能从不产生这个异常。“它是怎样做到的呢?”你可能好奇,“如果我申请多于系统提供的线程,使用std::thread和使用std::async有区别吗?”答案是有区别,因为当用默认发射策略(看条款36)调用std::async时,不能保证它会创建一个新的软件线程。而且,它允许调度器把指定函数(例如,doAsyncWork)运行在——请求doAsyncWork结果的线程中(例如,那个线程调用了get或者对fut使用wait ),如果系统oversubsrcibed或线程耗尽时,合理的调度器可以利用这个优势。

如果你想用“在需求函数结果的线程上运行该函数”来欺骗自己,我提起过这会导致负载均衡的问题,这问题不会消失,只是由std::async和调度器来面对它们,而不是你。但是,当涉及到负载均衡问题时,调度器比你更加了解当前机器发生了什么,因为它管理所以进程的线程,而不是只是你的代码。

使用std::async,GUI线程的响应性也是有问题的,因为调度器没有办法知道哪个线程需求紧凑的响应性。在这种情况下,你可以把std::lanuch::async发射策略传递给std::async,它那可以保证你想要运行的函数马上会在另一个线程中执行(看条款36)。

最新技术水平的线程调度器使用了系统范围的线程池来避免oversubscription,而且调度器通过工作窃取算法来提高了硬件核心的负载均衡能力。C++标准库没有要求线程池或者工作窃取算法,而且,实话说,C++11并发技术的一些实现细节让我们很难利用到它们。但是,一些供应商会在它们的标准库实现中利用这种技术,所以我们有理由期待C++并发库会继续进步。如果你使用基于任务的方法进行编程,当它以后变智能了,你会自动获取到好处。另一方面,如果你直接使用std::thread进行编程,你要承担着处理线程耗尽、oversubscription、负载均衡的压力,更不用提你在程序中对这些问题的处理方案能否应用在同一台机器的另一个进程上。

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

  • 你需要使用内部的特定平台线程的API。C++并发API通常是以特定平台的低级API实现的,通常使用pthread或Window’s Thread。它们提供的API比C++提供的要多(例如,C++没有线程优先级的概念)。为了获取内部线程实现的API,std::thread对象有一个native_handle成员函数,而std::future(即std::async返回的类型)没有类似的东西。
  • 你需要且能够在你的应用中优化线程的用法。例如,你要在一个固定的机器平台上部署一个单进程的服务器软件。
  • 你需要在C++并发API之上实现线程技术。例如,实现一个C++不提供的线程池。

不过,这些都是不常见的情况。大多数时候,你应该选择基于任务的设计,来代替线程。


总结

需要记住的3点:

  • std::thread的API没有提供直接获取异步运行函数返回值的方法,而且,如果这些函数抛出异常,程序会被终止。
  • 基于线程编程需要手动地管理:线程耗尽、oversubscription、负载均衡、适配新平台。
  • 借助默认发射策略的std::async,进行基于任务编程可以解决上面提到的大部分问题。
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值