std::thread

c++11在语言层面对并发编程提供了有力的支持,std::thread就是一例,它以线程的方式启动异步任务。

关于thread创建线程对象并使用的用法,请参考 std::thread创建线程的几种方式

使用thread对象,并在其上运行一个函数,这是基于线程的程序设计:

int DoAsyncWork();

std::thread t(DoAsyncWork);
std::async

还有一种更好的方式:基于任务的程序设计。即把任务函数传递给std::async,示例:

#include <future>

auto fut = std::async(DoAsyncWork); // fut是期值

之所以说基于任务的程序设计更好,理由如下:

  • 基于任务的方法可以获取函数DoAsyncWork的返回值,而基于线程的则困难的多
  • 基于任务的程序表现着更高阶的抽象,应用程序不必进行线程细节的管理

线程细节的管理,包括但不限于:

  • 当使用thread创建线程时,如果试图创建的线程数量多于系统能够提供的数据时,会抛出std::system_error异常
  • 即使没有用尽线程,也要注意超订问题(就绪状态的软件线程数量超过了硬件线程,线程调度器会为软件线程在硬件线程上分配CPU时间片,这会导致语境切换,进而导致线程开销增大)

而std::async则把所有问题扔给了C++标准库的实现者。除非你认为你可以比标准库做的更好,否则就不要尝试手动处理以上问题了。

关于std::async,需要知道它的两种启动策略:

  • std::launch::async: 函数f必须以异步方式运行,即在另一线程上执行
  • std::launch::deferred: 函数f只会在std:async所返回的期值的get或wait得到调用时才执行,即推迟执行到其中一个调用发生的时刻
  • 在调用get/wait时,f会同步运行
  • 调用方会阻塞到f运行结束
  • 如果get/wait都没有得到调用,f不会运行

而std::async的默认启动策略,并不是以上两个中的一个,而是对二者进行或运行的结果。下面两个调用意义相同:

auto fut1 = std::async(f); // 默认方式
auto fut2 = std::async(std::launch::async | std::launch::deferred, f); // 采用或者异步或者推迟的方式

这么一来,默认策略就允许f以异步或者同步的方式运行皆可。这种弹性使得标准库的线程管理组件能够承担得起线程创建、销毁、避免超订和负载均衡的任务。

std::async的注意事项

如前所述,以默认策略启动std::async时,会有以下问题:

  • 无法知道f是否会和t并发运行,因为f可能会被调度为推迟运行
  • 无法预知f是否运行在与调用fut的get/wait函数的线程不同的某个线程之上
  • 甚至无法预知f是否会得到运行,因为无法保证在程序的每条路径上,fut的get/wait都会得到调用
  • 在使用thread_local变量时会导致混淆,如果f读写此线程局部存储时,无法预知会取到哪个线程的局部存储
  • 可能导致基于wait的循环中以超时为条件者陷入死循环,因为对任务调用wait_for/wait_until会产生std::launch::deferred,如下:
using namespace std::literals // c++14持续时长后缀

void f()
{
	std::this_thread::sleep_for(1s);
}

auto fut = std::async(f); // 异步调用f,默认策略

while(fut.wait_for(100ms) != std::future_status::ready) { // 等待f完成,可能在此处死循环
	// ...
}

上例中,如果f是并发执行的,不会产生问题。

但如果f被推迟执行,fut.wait_for将总是返回std::future_status::deferred,所以陷入死循环。

避免这种问题也不难,只需要校验std:async的返回值,如果被推迟,则不要进入基于超时的循环:

auto fut = std::async(f);

if (fut.wait_for(0s) == std::future_status::deferred) {
	// f推迟运行,则使用fut的wait/get以异步方式调用f
} else {
	while (fut.wait_for(100ms) != std::future_status::ready) { // 不会进入死循环,前提是确保f会结束
		// f未被推迟,也未就绪。则做并发工作,直到任务就绪
	}
}

综上所述,只有在以下条件都满足时,才能使用std::async的默认启动策略:

  • 任务不需要与调用get/wait的线程并发执行
  • 读写哪个线程的thread_local变量无影响
  • 要么可以给出保证在std::sync返回的期值之上调用get/wait,要么可以接受任务可能永不执行
  • 使用wait_for/wait_until的代码将任务被推迟的可能性纳入考虑

其中任意一条无法保证时,请确保任务以异步方式执行:

auto fut = std::async(std::launch::async, f);

写个模板封装一下:

template<typename F, typename.. Ts>
inline std::future<typename std::result_of<F(Ts...)>::type>
reallyAsync(F&& f, Ts&&.. params)
{
	return std::async(std::launch::async, std::forward<f>(f), std::forward<Ts>(params)...);
}

auto fut = reallyAsync(f); // 以异步方式运行f
小结

对于异步任务,可以按以下建议使用:

  • 优先选用std::async,它是对线程更高层次的抽象
  • 优先使用std::async的默认策略,除非不满足上述使用条件,这会给予标准库更大的线程管理弹性
  • 如果不能使用默认策略,则使用std::launch::async
  • 如果std::async满足不了使用需求,则使用std::thread,如:
  • 需要访问底层线程实现的API,如pthread库,设置线程优先级和亲和性。std::thread提供了native_handle成员函数
  • 需要且能够为应用优化线程用法,如执行时的性能剖析情况已知,且作为唯一的主要进程部署在一种硬件特性固定的平台上
  • 需要实现超越c++并发API的线程技术,如在c++实现中未提供的线程池的平台上实现线程池
参考资料

《Effective Modern C++》