std::async:丢弃返回值为何会串行
两次调用都写了 std::launch::async,为什么任务还是一个接一个完成?先看返回的 future 有没有被保存。把返回值直接丢掉,临时对象会在该语句末尾销毁;如果它正在释放这个异步任务共享状态的最后一个引用,就可能在那里等待任务结束。第二条启动语句于是还没机会执行。
这类问题容易被误诊为线程没创建成功。更有用的排查顺序是:启动策略、返回对象的寿命、真正允许重叠的代码区间,最后才是性能。下面用不依赖睡眠和耗时阈值的 C++17 示例,把这几个问题分开。
分号也可能成为等待点
设想有两个独立的任务 A 和 B。程序连续写两条 (void)std::async(std::launch::async, task),表面上没有 get、wait,也没有显式 join。但“没有写等待函数”并不能推出“主线程不会等待”。返回对象的清理同样发生在控制流中。
第一条语句先启动 A,再清理返回的临时 future。等这条完整表达式结束,程序才执行第二条语句。如果清理要等 A 结束,那么 B 的启动点就在 A 的完成点之后。A 确实可以运行在另一个线程上,但两个任务没有因此获得重叠区间;“使用了线程”和“任务彼此并发”是两件不同的事。
如果 A 很快完成,清理时甚至看不到明显停顿。因此不应以某次计时很短来否定这个寿命问题。需要检查的是完成顺序约束,而不是凭日志时间差猜线程调度。
等待的是共享状态的寿命
future 是结果句柄,任务及其结果还关联着共享状态。不要把“一个变量离开作用域”简单等同于“所有任务都必须等待”:判断时要找出共享状态由谁创建、是否已就绪、当前释放是否为最后一个引用。
C++17 工作草案 [futures.state] 给出了释放共享状态时可能阻塞的例外条件;[futures.async] 则规定 async 策略下任务线程完成与等待或最后释放之间的同步关系。这不是所有 future 的通用“析构即 join”规则。由 promise 提供的 future 不能仅凭类型相同,就套用 async 创建状态的结论。
把 future 移动到外层变量,也只是在移动谁负责持有它。如果外层作用域马上结束,等待点可能只是从分号挪到了作用域末尾。若转成 shared_future 后还有副本,也要沿着最后一个持有者继续追踪。排查时在纸上画出持有关系,通常比只搜索 get 调用更可靠。
先保留句柄,再讨论任务重叠
示例用一个共享的“放行门”让两个任务都停在各自返回之前。A、B 各自通过 promise 通知主线程“已经到达门口”,主线程收到两份通知后才开门。只要程序能走到开门处,就说明两个任务都已启动,并且没有被第一条语句末尾的清理挡住。
这里证明的是任务生命周期可以重叠,而不是两个 CPU 核心一定同时执行指令。系统可以在同一个核心上轮流调度线程;我们也没有测吞吐、延迟或线程创建成本。这个区别很重要:验证控制流应使用同步协议,验证性能才需要测量与负载设计。
下面程序同时覆盖丢弃返回值、保存句柄、延迟执行和异常取回。把代码保存为 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 与共享状态。
如果这套不靠计时猜测的复现方法帮你定位了等待点,欢迎自愿支持后续技术写作。
支持
如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。
二维码可点击放大。更多支持方式见支持页面。


