比起基于线程编程,更偏爱基于任务编程
扫描二维码
随时随地手机看文章
如果你想异步地运行函数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”句柄,即不持有软件线程,因为它们处于默认构造状态(因此没有函数需要执行),它要么被move过了(那么接受移动的那个std::thread对象将代替这个std::thread对象来处理线程),要么被detach了(std::thread对象和它内部软件线程的关联被切断了,即thread对象和软件线程分离了)。
软件线程是一种受限的资源,如果你想创建的线程数量多于系统提供的数量,会抛出std::system_error异常。就算你规定函数不能抛出异常,这个异常也会抛出。例如,就算你把doAsyncWork
声明为noexcept,
int doAsyncWork noexcept; // 关于noexcept
这语句还是可能会抛出异常:
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有区别吗?”答案是有区别,因为当用默认启动策略(default launch policy)调用std::async时,不能保证它会创建一个新的软件线程。而且,它允许调度器把指定函数(本例中的doAsyncWork
)运行在——请求doAsyncWork
结果的线程中(例如,那个线程调用了get或者对fut使用wait ),如果系统oversubsrcibed或线程数耗尽时,合理的调度器可以利用这个优势。
如果你想用“在需要函数结果的线程上运行该函数”来欺骗自己,我提起过这会导致负载均衡的问题,这问题不会消失,只是由std::async和调度器来面对它们,而不是你。但是,当涉及到负载均衡问题时,调度器比你更加了解当前机器发生了什么,因为它管理所有进程的线程,而不是只是你的代码运行于的进程和线程。
使用std::async,GUI线程的响应性也是有问题的,因为调度器没有办法知道哪个线程具有严格的响应性要求。在这种情况下,你可以把std::lanuch::async启动策略传递给std::async,它那可以保证你想要运行的函数马上会在另一个线程中执行。
最先进的线程调度器使用了系统范围的线程池来避免oversubscription,而且调度器通过工作窃取(workstealing)算法来提高了硬件核心的负载均衡能力。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,进行基于任务编程可以解决上面提到的大部分问题。