Item 35: Prefer task-based programming to thread-based.
如果你想异步运行一个函数 donAsyncWork
,你有两个基本的选择:基于线程的方法(thread-based)和基于任务的方法(task-based)。
int doAsyncWork();
std::thread t(doAsyncWork); // thread-based
auto fut = std::async(doAsyncWork); // task-based
在比较二者优劣前,我们先介绍下 C++ 软件中线程的3个层次:
- 硬件线程。硬件实际执行计算的并行数。现代计算机架构中,一个硬件核对应一个或多个硬件线程。
- 软件线程。也被称为系统线程,指的是操作系统管理核调度的所有线程。软件线程运行在硬件线程之上,并且可以创建的软件线程要多于硬件线程。这样的好处是:当某些软件线程处于阻塞状态(等待IO、mutex、condition variable)时,可以执行其他线程以提高吞吐率。
- std::thread。C++ 的线程对象,作为句柄对应系统的软件线程。
std::thread
也可以是空句柄而不对应系统的软件线程。例如没有执行函数、执行函数被移动其他线程、已经join
或detached
的std::thread
对象。
基于任务的方法一般要优于基于线程的方法。
doAsyncWork
有返回值,可以代表任务的执行状态。基于线程的方法没有提供一个很好的机制获取返回值。而 std::async
返回的 std::future
对象提供了 get
方法可以获取到返回值。并且当 doAsyncWork
返回异常时,基于线程的方法直接抛出 std::terminate
,而基于任务的方法可以根据返回值做异常处理。
系统的软件线程是有限的,当请求创建的 std::thread
多于系统提供的最大软件线程数,将抛出 std::system_error
,即使 doAsyncWork
被设置成 noexcept
。因而基于线程的方法需要处理这种情况,这就需要对线程进行管理。
即使你没有用尽软件线程,基于线程的方法还存在认购超额(oversubscription)的问题,即就绪态的软件线程高于硬件线程。操作系统会采用时间片轮询的方式执行所有的软件线程,而线程的上下文切换会增加线程管理的开销。并且硬件线程被切换到另一个软件线程时,其 cache 上的数据通常会失效,也会增加线程的开销。想要避免认购超额问题还比较困难,软件线程于硬件线程的合理比例取决于多种因素。例如硬件架构的特点、cache的使用方式、任务的特点等。
综上,线程的管理是比较困难的。而基于任务的方法将线程管理交给了 C++ 标准库,而 C++ 标准库可以更好地管理线程。例如,你无需担心软件线程耗尽的问题,因为默认参数的 std::async
不一定会创建线程,它可能在认购超额时将当前任务安排在当前线程上执行。另外 C++ 标准库可能比你更清楚硬件线程的资源,可以很好的避免负载不均衡的问题。
当然,基于线程的方法也有一定的优势:
- 需要访问实现线程的底层API。std::thread 可以获取底层线程的句柄,可以使用底层线程的API。
- 需要优化线程的使用。例如,如果你正在开发一个服务软件,而这个软件是这台机器上执行的唯一有意义的进程,并且你清楚这台机器的硬件配置。
- 需要实现一些高级的线程技术。例如线程池技术,而 C++ 标准库没有提供。
除了上述情况外,建议优先使用基于任务的编程方法。
参考: