条款36 :如果异步是必要的,则指定 std::launch::async

ZaynPei Lv6

该条款的核心论点是,std::async 的默认启动策略虽然为标准库调度器提供了有用的灵活性,但也给程序员带来了不确定性,可能导致难以发现的bug。因此,当你确实需要函数必须异步执行时,就应该明确指定这一点。

std::async 的启动策略

std::async 可以接受一个启动策略参数,来控制任务的执行方式: - std::launch::async:此策略保证任务会在一个不同的线程上异步执行 。 - std::launch::deferred:此策略意味着任务会被延迟执行。它只会在返回的 std::future 对象上调用 get() 或 wait() 时,才同步地(即阻塞地)在调用 get() 或 wait() 的那个线程上执行。如果 get() 或 wait() 一直未被调用,任务将永远不会执行 。 - 默认启动策略:如果你不指定任何策略,std::async 会使用 std::launch::async | std::launch::deferred 的组合策略 。

默认的组合策略赋予了标准库调度器极大的灵活性,让它可以根据系统当前的线程负载情况来决定任务的执行方式,这有助于避免线程耗尽和超订问题(如条款35所述)。然而,这种灵活性也给程序员带来了几个严重的不确定性:

  • 无法预知任务是否并发运行:你无法保证任务 f 会与调用 std::async 的线程 t 并发执行 。
  • 无法预知任务运行在哪一个线程上:你无法确定任务 f 是运行在一个新线程上,还是运行在对 future 调用 get() 或 wait() 的那个线程上 。这对 thread_local 变量的使用会产生影响 。
  • 无法保证任务一定会被执行:如果 std::async 返回的 future 在程序的任何路径上都未被调用 get() 或 wait(),那么被延迟执行的任务可能永远不会启动。

核心陷阱:基于超时的 wait 与无限循环

默认策略最危险的陷阱在于它与基于超时的等待函数(如 wait_for)的交互

1
2
3
4
5
6
7
8
9
10
11
using namespace std::literals;
void f() {
std::this_thread::sleep_for(1s);
}

auto fut = std::async(f); // 采用默认启动策略

// 意图是每 100ms 检查一次任务是否完成
while (fut.wait_for(100ms) != std::future_status::ready) {
// ... 这个循环可能永远不会结束!
}

如果调度器因为系统负载过高等原因,为任务 f 选择了 std::launch::deferred 策略,那么 fut.wait_for(…) 的返回值将永远是 std::future_status::deferred。这也就意味着, fut.wait_for(…) 的结果永远不等于 std::future_status::ready,导致 while 循环成为一个无限循环。这个bug非常隐蔽,因为它可能只在高负载的生产环境中才会出现。

解决方案:显式指定启动策略

该条款给出的结论非常明确:如果你需要确保任务必须异步执行,从而避免上述所有不确定性和陷阱,你就必须在调用 std::async 时显式指定 std::launch::async 启动策略。

1
auto fut = std::async(std::launch::async, f); // 保证 f 会异步执行
这样做之后,f 一定会在一个不同的线程上运行,你可以安全地使用基于超时的 wait,并且不必担心任务会因为未调用 get 或 wait 而不被执行。

On this page
条款36 :如果异步是必要的,则指定 std::launch::async