std::async:丢弃返回值为何会串行

黎 浩然/ 24 10 月, 2026/ 教程/TUTORIAL, 计算机/COMPUTER/ 0 comments

两次调用都写了 std::launch::async,为什么任务还是一个接一个完成?先看返回的 future 有没有被保存。把返回值直接丢掉,临时对象会在该语句末尾销毁;如果它正在释放这个异步任务共享状态的最后一个引用,就可能在那里等待任务结束。第二条启动语句于是还没机会执行。

文章目录
  1. 分号也可能成为等待点
  2. 等待的是共享状态的寿命
  3. 先保留句柄,再讨论任务重叠
  4. 默认策略和 deferred 会改变解释
  5. wait、get 和销毁承担不同责任
  6. 修复应当把责任放到可见位置

这类问题容易被误诊为线程没创建成功。更有用的排查顺序是:启动策略、返回对象的寿命、真正允许重叠的代码区间,最后才是性能。下面用不依赖睡眠和耗时阈值的 C++17 示例,把这几个问题分开。

分号也可能成为等待点

设想有两个独立的任务 A 和 B。程序连续写两条 (void)std::async(std::launch::async, task),表面上没有 get、wait,也没有显式 join。但“没有写等待函数”并不能推出“主线程不会等待”。返回对象的清理同样发生在控制流中。

第一条语句先启动 A,再清理返回的临时 future。等这条完整表达式结束,程序才执行第二条语句。如果清理要等 A 结束,那么 B 的启动点就在 A 的完成点之后。A 确实可以运行在另一个线程上,但两个任务没有因此获得重叠区间;“使用了线程”和“任务彼此并发”是两件不同的事。

如果 A 很快完成,清理时甚至看不到明显停顿。因此不应以某次计时很短来否定这个寿命问题。需要检查的是完成顺序约束,而不是凭日志时间差猜线程调度。

启动 A → 临时 future 销毁 → A 完成后越过分号
随后启动 B → 临时 future 销毁 → B 完成
图1:丢弃返回对象时,第一条语句的清理可以把第二次启动挡在后面。箭头表示顺序,不代表实测耗时。

等待的是共享状态的寿命

future 是结果句柄,任务及其结果还关联着共享状态。不要把“一个变量离开作用域”简单等同于“所有任务都必须等待”:判断时要找出共享状态由谁创建、是否已就绪、当前释放是否为最后一个引用。

C++17 工作草案 [futures.state] 给出了释放共享状态时可能阻塞的例外条件;[futures.async] 则规定 async 策略下任务线程完成与等待或最后释放之间的同步关系。这不是所有 future 的通用“析构即 join”规则。由 promise 提供的 future 不能仅凭类型相同,就套用 async 创建状态的结论。

把 future 移动到外层变量,也只是在移动谁负责持有它。如果外层作用域马上结束,等待点可能只是从分号挪到了作用域末尾。若转成 shared_future 后还有副本,也要沿着最后一个持有者继续追踪。排查时在纸上画出持有关系,通常比只搜索 get 调用更可靠。

先保留句柄,再讨论任务重叠

示例用一个共享的“放行门”让两个任务都停在各自返回之前。A、B 各自通过 promise 通知主线程“已经到达门口”,主线程收到两份通知后才开门。只要程序能走到开门处,就说明两个任务都已启动,并且没有被第一条语句末尾的清理挡住。

这里证明的是任务生命周期可以重叠,而不是两个 CPU 核心一定同时执行指令。系统可以在同一个核心上轮流调度线程;我们也没有测吞吐、延迟或线程创建成本。这个区别很重要:验证控制流应使用同步协议,验证性能才需要测量与负载设计。

主线程:保存 a、b → 等到两份 started 通知 → 开门
工作线程:通知已启动 → 等 gate → 返回结果
图2:由主线程在两个任务都已启动后统一放行。它验证重叠的任务寿命,不把调度时序当成性能结论。

下面程序同时覆盖丢弃返回值、保存句柄、延迟执行和异常取回。把代码保存为 async_future.cpp,可用 clang++ -std=c++17 -pthread async_future.cpp -o async_future 编译,再运行生成的程序。编译时保留断言,不加 -DNDEBUG。本地已按 C++17 编译执行;输出只是这些教学检查通过,不是跨平台性能评测。

#include <atomic>
#include <cassert>
#include <chrono>
#include <future>
#include <iostream>
#include <stdexcept>
#include <thread>
using namespace std::chrono_literals;
int main() {
  std::atomic<int> done{0};
  // A discarded async future is destroyed at the semicolon.
  (void)std::async(std::launch::async, [&] { ++done; });
  assert(done == 1);
  (void)std::async(std::launch::async, [&] { assert(done == 1); ++done; });
  assert(done == 2);

  std::promise<void> release, started_a, started_b;
  auto gate = release.get_future().share();
  auto a = std::async(std::launch::async, [&] {
    started_a.set_value(); gate.wait(); return 10;
  });
  auto b = std::async(std::launch::async, [&] {
    started_b.set_value(); gate.wait(); return 20;
  });
  started_a.get_future().get();
  started_b.get_future().get();
  assert(a.wait_for(0ms) == std::future_status::timeout);
  assert(b.wait_for(0ms) == std::future_status::timeout);
  release.set_value();
  assert(a.get() + b.get() == 30);
  assert(!a.valid() && !b.valid());

  int deferred_calls = 0;
  {
    auto unused = std::async(std::launch::deferred, [&] { ++deferred_calls; });
    assert(unused.wait_for(0ms) == std::future_status::deferred);
  }
  assert(deferred_calls == 0);
  const auto caller = std::this_thread::get_id();
  auto deferred = std::async(std::launch::deferred, [&] {
    ++deferred_calls; return std::this_thread::get_id();
  });
  assert(deferred.get() == caller);
  assert(deferred_calls == 1);

  auto failure = std::async(std::launch::async, []() -> int {
    throw std::runtime_error("teaching failure");
  });
  failure.wait();
  assert(failure.valid());
  bool caught = false;
  try { (void)failure.get(); }
  catch (const std::runtime_error&) { caught = true; }
  assert(caught && !failure.valid());
  std::cout << "async lifetime, retained overlap, deferred, exceptions: passed\n";
}

第一段在两个完整表达式后分别检查 done,观察丢弃句柄后的完成顺序。第二段不靠“睡一会儿应该启动了”的假设,而是等待 started_a、started_b 的通知。两个 wait_for(0ms) 返回 timeout 的理由也明确:任务被尚未打开的 gate 阻止返回,不是机器恰好比较慢。

不要把门的示例改成“丢掉 A 的 future,再由主线程开门”。那样主线程可能在第一条语句清理时就等 A,而 A 又等主线程开门,形成互相等待。本例把两个 future 保存到门打开之后,正是为了避免这个依赖环。测试出现卡住时,先画等待关系,不要先调大超时。

默认策略和 deferred 会改变解释

省略启动策略不代表保证开新线程。默认策略允许 async 或 deferred;若实际选择延迟执行,任务可能直到非定时等待时才在等待者线程运行。因此,研究返回对象的异步等待行为时,示例明确写 async,避免把两个变量混在一起。

代码的 deferred 分支刻意展示另一条路径:先检查状态,再销毁未等待的句柄,调用次数仍为零;另一个延迟任务在 get 时运行,并返回调用者线程 ID。由此不能推导“丢弃返回值总会执行任务”,也不能把默认策略当成可靠的后台任务接口。

实际排错时,把策略写进复现程序和记录。如果本来需要允许延迟执行,应保留这个选择,并接受相应语义;如果需要异步启动,应显式选择 async,同时处理资源不足时的失败。策略明确解决的是启动契约,不能自动解决限流、任务数量或系统负载。

wait、get 和销毁承担不同责任

最后一段把工作函数的失败保存到共享状态中。wait 等待就绪,随后 future 仍有效;get 才取回结果或重新抛出保存的异常,并使这个普通 future 失去有效状态。这个示例只验证工作函数抛出的异常,没有注入线程创建失败;门控演示不能直接作为生产任务管理器。代码明确捕获工作异常,避免“任务结束了”被误当成“任务成功了”。

仅仅让返回对象最终销毁,不是应用层的错误处理方案。需要记录失败、重试或给调用方反馈时,应在清楚的责任边界取回结果。多个任务的结果收集也要考虑:一个 get 抛出后,其余句柄仍由谁持有、剩余工作如何收尾。不要为了让演示短一点,忽略真实服务中这段控制流。

对于请求处理函数,尤其要检查返回路径与异常路径:即便正常路径显式 get,提前 return 也可能让等待落到局部变量的清理中。把“这段操作最多多久”建立在请求超时上,而没有说明任务如何结束,仍然没有完成生命周期设计。相关边界可对照异步超时与后台线程的关系,两篇使用不同语言,不能混用具体 API 规则。

修复应当把责任放到可见位置

独立的有限任务需要重叠执行,可以先保存所有 future,再在合适位置收集结果;示例展示了这个最小结构。任务相互依赖时,先识别依赖,不能机械地把 get 全部搬到末尾。若函数必须快速返回,就要让更长寿命的管理对象接管任务及结果;仅仅把句柄扔掉,不能建立这种所有权。

任务量较大或长期运行时,还需要队列、并发上限、关闭过程与错误汇报。本文没有实现线程池或任务管理器,也没有验证取消协议。判断是否需要这些设施,应依据服务的负载与生命周期,而不是把一次异步调用包装成“即发即忘”。

排查最终落在一个具体问题:在哪一行,谁持有任务结果,谁保证工作收尾?把它回答清楚,才能决定这个等待点是合理的资源管理,还是意外拖慢了调用方。

Read the complete English version。本文原计划日期为2026年10月24日09:00(北京时间),实际于2026年10月29日补发;引用的C++17草案早于计划日期。

参考:C++17工作草案 N4659,async 与共享状态。

如果这套不靠计时猜测的复现方法帮你定位了等待点,欢迎自愿支持后续技术写作。

支持

如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。

微信支持二维码,点击查看大图
微信
支付宝支持二维码,点击查看大图
支付宝
Buy Me a Coffee,点击支持本站
Buy Me a Coffee

二维码可点击放大。更多支持方式见支持页面。

Share this Post

Leave a Comment

您的邮箱地址不会被公开。 必填项已用 * 标注

*
*