Effective modern C++ 条款37:基于任务编程优先于基于线程编程(Prefer task-based programming to thread-based)

本文讨论了在C++中,基于任务(task-based)编程相对于基于线程(thread-based)编程的优势。通过对比std::thread和std::async的使用,指出基于任务的编程能更方便地获取返回值和处理异常,同时也避免了线程管理、超额认购和负载均衡的问题。现代线程调度器通过线程池和工作窃取算法优化了这些问题,而基于任务的编程能自然地利用这些优化。
摘要由CSDN通过智能技术生成

    有两种方法可以用来异步的运行一个函数doAsynWork。你可以创建一个std::thread来运行doAsynWork,以下是基于线程(thread-based)的方法:   

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

    或者你可以将doAsynWork作为参数传入std::async,这种方法称为基于任务(task-based):

   

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

   这个调用中,传入std::async接口的可执行体(即,doAsyncWork)称之为一个任务(task)。

    相对于基于线程的方法,通常优先选择其对应的基于任务的方法,从以上的两行代码也能够说明一二。就这个例子而言,doAsyncWork有一个返回值,我们可以合理的假设调用者对是期望知道这个返回值的。对于基于线程的方法,并没有直接明了的方法来获取这个返回值。而这对于基于任务的方法却十分简单,因为从std::async方法返回的funture对象提供了一个get方法可以获取这个返回值。除此以外,当doAsyncWork函数中有异常抛出时,get方法显得更为重要,因为它也能够获取这些异常。而对于基于线程的方法,当doAsyncWork中有异常抛出时,程序直接结束(通过调用std::terminate,或者如果通过调用经set_terminate设置的函数)。

    二者一个更为基本的区别是基于任务方法的高度抽象,它将你从线程管理的细节中解放出来。对于线程管理,有必要在此总结在并发的C++软件中“线程”的三重意思:

  • 硬件线程(Hardware threads)是指实际执行计算的线程。现代的机器架构中,每个CPU提供一个或多个硬件线程。
  • 软件线程(Software threads)(也称为OS线程或者系统线程)是指那些由操作系统管理,在所有在硬件线程上执行任务的进程和任务。通常可以创建的软件线程数比硬件线程数多很多,因为当一个软件线程被阻塞(比如,在进行I/O或者等待一个mutex或是条件变量)时,可以执行其它处于非阻塞状态的线程,以提高性能。
  • std::thread是C++进程中的对象,类似于潜在的软件线程的句柄。有些std::thread对象是空句柄,即它底层并没有任何的软件线程,例如它们是由默认构造函数所构造(因此没有执行函数)、被move至另外一个线程(那个std::thread对象便代表了潜在的软件线程的句柄)、或者被detached(它们与潜在的软件线程的连接被断开了)。

    软件线程是有限的资源。当请求创建的线程数超过了系统所能提供的软件线程数,便会抛出std::system_error异常。即使你想运行的函数声明了不能抛出异常,这个异常同样会被抛出。例如,即使doAsyncWork声明为noexcept,  

int doAsyncWork() noexcept; 

下面的语句同样或导致异常被抛出:   

std::thread t(doAsyncWork); // throws ifno more threads are available 

  好的软件并需通过某种方法来处理这种可能性,但是怎么处理?一种方法就是在当前线程上运行doAsynchWork,但这会导致负载不均衡,并且如果当前线程是GUI线程,则会导致无响应问题。另一种选择就是等待其他已经存在的线程完成后,再尝试创建std::thread来运行,但有可能已经存在的线程正在等待doAsynchWork中的某个动作(例如,产生一个结果或者通知一个条件变量)。

   即使你没有用尽软件线程,基于线程也存在认购超额(oversubscription)的问题,即处于准备就绪(ready-to-run)状态的软件线程数量多于硬件线程数量。当这种情况发生时,线程调度器为每一个软件线程在硬件线程上分配时间切片(time-slices)。当一个线程的时间切片用完时,将使用权交给另外一个线程,此时需要进行上下文切换。这种上下文切换会降低系统的线程管理器的性能,并且当硬件线程需要运行的软件线程的上一个时间切片是在另外一个核时,这种切换会变得尤为昂贵。这种情况下,(1)CPU的缓存通常对新的软件线程是没有用的,并且(2)在那个核上运行的“新”的软件线程会“污染”CPU为“老”的软件线程的缓存内容,而这个“老”线程很有可能会重新被分配到这个核上运行的。

    要避免超额认购问题比较困难,因为软件线程与硬件线程的最佳比例取决于软件线程处于准备就绪状态的频率,而这个频率是动态变化的,即,一个程序从I/O密集型到计算密集型的转换。最佳的比例同样也与上下文切换代价以及软件线程使用CPU缓存的效率有关。甚至硬件线程数以及CPU缓存的细节(如它们的大小及相对的速度)也影响最佳比例,而这是与机器架构息息相关的。所以,即使你尽所有努力在一个平台上避免超额认购(同时让硬件处于繁忙状态),这样不能保证你的解决方案在别的机器上同样有效。

    如果你将这些问题抛给别人来处理,那事情则会变得简单许多,而std::async正好可以为你做这件事。      

auto fut = std::async(doAsyncWork); // onus of thread mgmt is on implementer of theStandard Library

    这个调用将所有线程管理的责任全部交给C++标准库的实现。比如,你不必担心线程用尽(out-of-threads)的异常,因为这个调用绝不会产生这个异常。你也许会问,“这是怎么做到的?”。“如果我请求的线程数多于系统所能够提供的,为什么与我是通过创建std::thread还是调用std::async有关呢?”这是所以是个问题,是因为当采用这种形式调用std::async时(系统使用默认的模式-见Iterm38),并不保证一定会创建一个软件线程。然而,它运行调度器安排执行体(在这个例子中是doAsyncWork)在当前线程中运行,而当存在认购超额或者软件线程用完时,线程调度器便会采取这种方式。

    如果你要自己使用这个技巧,当然可以,但是我之前曾提过这有可能会导致负载不均很或者无响应问题,并且这些问题不会仅仅因为你使用的是std::async而消失,只是调度器会帮你来解决这些问题。对于负载均衡,调度器显然比你更加了解机器发生了什么,因为它管理所有进程的线程,并不仅仅是运行你的代码的那个进程。

    在GUI线程中调用std::async时,无响应依然是一个问题,因为调度器无法知道哪个线程对响应有较高的要求。这种情况,你或许想要传递std::lauch::async运行原则至std::async接口,因为这个将保证执行函数一定会在另外一个线程上执行(详见Iterm 38)。但是这种情况下,你必须处理可能的异常,因为就像std::thread的构造函数一样,利用std::lauch::async来调用std::async有可能会用尽线程并抛出一个std::system_error异常。

    现代的线程调度器采用全路(system-wide)线程池来避免超额认购,它们通过工作窃取(work-stealing)算法来提升硬件多核之间的负载均衡。C++标准并不要求使用线程池或者工作窃取,但是它并没有严令禁止。事实上,一些库在它们对标准库的实现中已经利用了这些技术,并且后续还将有更多。如果你在你的并发程序中采用基于任务的方式,由于这些技术应用广泛,你将自动从这些技术中受益。但是如果你使用std::thread,那你不得不处理线程用尽的异常,超额认购,以及负载均衡等问题,当这些问题与运行在同一机器的其他进程之间的解决方法了。

    相比于基于线程编程,基于任务编程让你不必关心线程管理的琐事,并且能够提供一种自然的方法来检测异步执行函数的结果(即,返回值或者异常)。但是也有一些情况,使用线程是合适的,它们包括:

  • 你需要访问实现线程的底层API。

    C++并行API通常是使用底层系统的API实现的,一般为pthreads或者Windows的Thread。目前这些API比C++提供的接口更丰富,(例如,C++没有线程属性等)。为了提供访问这些API的方法,std::thread对象提供了native_handle成员函数。而由std::async返回的std::future对象则没有对应的方法。

  • 你需要并且能够在你的程序中优化线程的使用。

    举个例子,如果你正在开发一个服务软件,而这个软件是这台机器上执行的唯一有意义的进程,并且你知道这台机器的硬件配置。

  • 你需要在C++并行API上实现新的线程技术。

    比如,在一个C++实现中没有提供线程池的平台上去实现自己的线程池。

    然而这些都是不常见的情形,大多数情况下,你应该选择基于任务的实现方式,而非基于线程的方式。

前情提要

  • std::thread的API中没有直接提供获取执行函数的返回值的方法,如果执行函数中抛出异常,那么程序将结束。
  • 基于线程编程需要手动处理线程耗尽、超额认购、负载均衡以及新平台适应等问题。
  • 通过默认方式调用std::async的基于任务方式不存在以上任何缺点。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值