线程式编程与任务式编程
如果当前需要异步运行函数doAsyncWork,我们有两种基本操作。第一种利用std::thread创建线程然后在其上运行doAsyncWork,也就是线程式并发编程:1
2int doAsyncWork();
std::thread t(doAsyncWork);
又或者你可以将doAsyncWork传递给std::async,也就是所谓的任务式并发编程:1
auto fut = std::async(doAsyncWork); // "fut" for "future"
在上述调用中,传递给std::async的函数对象(doAsyncWork)被认为是一项任务。
任务式并发编程通常优于线程式并发编程,这一点在上述实例中已经有所体现。doAsyncWork存在一个返回值,我们可能在程序运行期间需要使用其返回值,但线程式并发编程无法直接获取这个值,而任务式并发编程则可以,因为std::async返回的future提供了get。此外,如果doAsyncWork抛出异常,我们也可以通过get访问该异常,而线程式并发编程则将直接崩溃(调用std::terminate)。
我们可以认为,线程式与任务式的最本质区别在于任务式具备更高的抽象层次,从而使得开发者不必关注线程管理细节。
线程特性
在C++并发编程中,“thread”具备3大含义:
- Hardware thread
硬件线程是实际执行计算的线程,当代计算机架构为每个CPU核心提供一个或多个硬件线程。 - Software thread(OS thread or system thread)
软件线程(也称为OS线程或者系统线程)是指那些由操作系统管理,在所有在硬件线程上执行任务的进程和任务。通常可以创建比硬件线程更多的软件线程,因为当软件线程被阻塞时(例如运行于IO以及等待mutex斥或condition variable),可以通过执行其他未阻塞的线程来提高吞吐量。 - std::thread
std::thread是C++进程中的对象,它充当底层软件线程的句柄。一些std::thread对象表示“null”句柄(即不对应于任何软件线程)。造成这种现象的原因大致有四种:处于default construct state(没有需要执行的函数),已被移动至另一个线程(该std::thread对象便代表了底层软件线程的句柄),已经joined(要运行的函数已经完成)以及已被detached(它与其底层软件线程的联系已被切断)。
线程式缺陷
软件线程是受限的资源,如果尝试创建超过系统可以提供的内容,则会抛出std::system_error异常,即使您要运行的函数不能抛出异常也是如此,也就是说即使doAsyncWork是noexcept:1
int doAsyncWork() noexcept;
如下语句也可能会抛出一个异常:1
std::thread t(doAsyncWork); // throws if no more threads are available
作风优良的软件必须以某种方式处理这种可能性,但我们应当如何实现?一种方法是在当前线程上运行doAsyncWork,但这可能导致不平衡的负载,并且如果当前线程是GUI线程则又将引起响应性问题。另一个选择是等待一些现有的软件线程执行完毕,然后再次尝试创建一个新的std::thread,但现有的线程可能正在等待doAsyncWork应该执行的操作。
即使你没有将所有线程资源消耗殆尽,也可能会触发oversubscription(即处于ready-to-run的软件线程要多于硬件线程)。当发生这种情况时,线程调度程序(通常是OS的一部分)会对硬件线程上的软件线程进行时间分片。当某个线程的时间片用完时,硬件使用权将交付给另一个线程,完成context switch。context switch增加了系统的整体线程管理开销,并且当硬件线程需要运行的软件线程的上一个时间切片是在另外一个核时,这种切换会变得尤为昂贵。在这种情况下
- CPU的缓存通常对新的软件线程帮助较少(只包含零丁有用的数据和指令)
- 新线程运行的内容可能会污染该核的缓存,因为老进程可能马上将回归该核继续执行
避免oversubscription十分困难,因为软件与硬件线程的最佳比率取决于软件线程的运行频率,并且该频率是时变而非定常的(例如,当程序从I/O密集区转向计算密集区时)。软件线程与硬件线程的最佳比例还取决于context switch的成本以及软件线程如何有效地使用CPU高速缓存。此外,硬件线程的数量和CPU缓存的具体细节(大小及其相对速度)取决于计算机架构,因此即使调整应用程序以避免oversubscription(同时仍保持硬件繁忙),也无法保证在同一平台下的其他计算机能够流畅运行。
std::async优势
上述难以解决的问题最好交给专业人士解决,而std::async就是那个专业人士:1
2// onus of thread mgmt is on implementer of the Standard Library
auto fut = std::async(doAsyncWork);
此调用将线程管理职责转移到C++标准库的实现者,因此,我们将再也不用担心线程耗尽而产生异常,因为这根本不会产生一个新线程。这一点十分重要,因为std::async以这种方式启动时(即Item38所说的默认启动方式),并不保证它一定会产生一个新线程。事实上,而当存在oversubscription或者线程耗尽时,它将运行调度器安排执行体(在这个例子中是doAsyncWork)在当前需要doAsyncWork返回结果的线程中运行。
如果你要自己模拟这个功能,这当然能够实现,只是这有可能会导致负载失衡或者GUI系统中的响应问题,并且这些问题不会仅仅因为你使用的是std::async而消失,只是在std::async中使用调度器帮你解决了这个问题。调度器显然比你更加了解当前机器的负载问题,因为它管理所有进程的线程,而非只了解运行你的代码的那个进程。即使使用std::async,GUI程序仍然会存在响应性问题,因为调度器并不明确哪些线程存在较高的响应需求。在这种情况下,我们应当将std::launch::async启动策略传递给std::async,这将确保要运行的函数真正运行于不同的线程之上(参见条款36)。
目前最先进的线程调度器采用system-wide线程池来避免oversubscription,并且它们还会通过work-stealing算法改善CPU核之间的负载平衡。C++标准并不强制使用线程池或work-stealing算法,而且说实话,某些C++11并发规范的技术层令使用它们比我们想象地更难。尽管如此,一些厂商仍然在其标准库实现中使用了这些技术,并且我们有理由相信会有越来越多的厂商加入他们。如果我们采用任务式并发编程,那些这些技术将自动为我们所用,反之,如果你采用std::thread,那你将不得不手动处理线程耗尽、在oversubscription、负载均衡等等问题。
std::thread优势
尽管任务式并发编程优势巨大,但我们在以下三种情况还是不得不使用std::thread:
- 需要获取底层线程实现的API
C++并发API通常使用较低级别平台的特定API实现,通常是pthreads或Windows下的Threads。 目前这些API比C++提供的接口更为丰富。(比如C++没有线程优先级或affinities的概念。)std::thread对象通常具备native_handle成员函数以便于开发者访问底层实现API,而std::future没有对应的功能。 - 需要针对特定应用完成线程优化
具体实例可以是当前我们正在开发一款具有已知执行配置文档的服务器软件,该软件将被部署为某机器的唯一进程。 - 需要实现C++并发API之外的线程技术
例如实现一个线程池等等。
总结
- std::thread的API中没有直接提供获取执行函数的返回值的方法,如果执行函数中抛出异常,那么程序将立即终止。
- 基于线程编程需要手动处理线程耗尽、oversubscription、负载均衡以及平台适应性等问题。
- 通过默认方式调用std::async的任务式并发编程不存在以上任何缺点。