前言
std::async是 C++11 引入的高层并发接口,它想解决的是std::thread用起来"太底层"的问题:用std::thread,你得自己管线程的生命周期(join还是detach)、自己想办法把返回值传回来、自己把异常从工作线程搬到调用线程。std::async把这些包成了一个动作——"把这个函数丢出去跑,给我一张取结果的凭据",凭据就是std::future,异常会在get()的地方被重新抛出。
关于它,有三个高频误解:
第一,以为std::async一定会开新线程。默认启动策略是std::launch::async | std::launch::deferred,也就是说"可以异步执行,也可以延迟执行",由实现挑一个。如果它挑了延迟执行(deferred),函数根本不会在后台跑,而是等到你调用get()或wait()的那一刻,在调用线程上执行。
第二,以为丢掉返回值只是"浪费了一个 future"。对launch::async启动的任务,future的析构会阻塞到任务完成——所以std::async(std::launch::async, f);这种写法会让代码看起来并发、实际完全串行。
第三,以为future::get()可以反复调用。get()是"取走"语义:调用一次后valid()变成false,再调用是未定义行为(UB)。想多方读取要用std::shared_future。
本文按"启动策略 → future 语义 → 异常传播与实战 → 坑点"的顺序讲。代码以 C++17 为基准,用到的都是 C++11 就有的设施,在 GCC 13 / Clang 17 / MSVC 19.3x 上均可编译,GCC/Clang 需要加-pthread。
一、两种重载与启动策略
std::async有两个重载,区别就在于第一个参数是不是启动策略:
#include <future> int work(int a, int b); // 重载 1:不接受策略参数,等价于传入 std::launch::async | std::launch::deferred auto f1 = std::async(work, 1, 2); // 重载 2:第一个参数是启动策略 auto f2 = std::async(std::launch::async, work, 1, 2); auto f3 = std::async(std::launch::deferred, work, 1, 2);两个重载的返回类型都是std::future<R>,R是用给定实参调用该可调用对象的结果类型(C++17 起按std::invoke_result推导,它之前的名字是std::result_of)。推导规则以及"参数按值退化拷贝"的行为,和std::thread的构造函数完全一致——这一点很关键。
启动策略(std::launch是一个位掩码风格的枚举,可以用|组合)的语义差别:
| 策略 | 执行线程 | 何时开始执行 | wait_for的返回值 | 典型用途 |
|---|---|---|---|---|
std::launch::async | 新线程(实现可以选择复用,不保证) | 立即 | ready/timeout | 真正要并行 |
std::launch::deferred | 调用get()/wait()的那个线程 | 惰性,第一次get()/wait()时才做 | 立即返回deferred | 延迟求值 |
| 两者按位或(默认) | 由实现选 | 由实现选 | 视实现选择而定 | 不推荐:行为不可预期 |
std::launch::deferred有一个很有用的性质:如果从头到尾没人get()/wait(),这个函数就永远不执行。这可以拿来做惰性求值,但也正是异常会被静默丢弃的原因。
反过来,launch::async有一个必须知道的副作用:它的future析构会阻塞。C++11 起的标准规定,由std::async创建、策略包含launch::async、且尚未就绪的那些future,最后一个被销毁时会阻塞到任务完成——这就是"丢掉返回值就变成串行"的确切原因。
另外,标准并不要求std::async复用线程。以 libstdc++ 的实现为例(实现细节,不是标准规定),launch::async下每次调用都会新建一个std::thread,所以它不适合替代线程池。
std::async和手写std::thread的对比大致是:
| 维度 | std::thread | std::async |
|---|---|---|
| 必须做的收尾 | 析构前必须join()或detach(),否则std::terminate | 不用,future析构会处理(可能阻塞) |
| 取返回值 | 得自己用引用/指针参数带出来,容易出竞态 | future::get()直接返回 |
| 异常传播 | 线程里未捕获的异常直接std::terminate | 捕获并存入共享状态,get()时重抛 |
| 何时开始执行 | 一定立即 | 取决于启动策略,可能永不执行 |
| 参数传递 | 按值拷贝(引用要用std::ref) | 同上 |
| 线程复用 | 无,一个thread一个线程 | 无保证(libstdc++ 也是每次新建) |
二、std::future的语义:一次取值、两种查询
std::future是一个只移动、不可拷贝的句柄,代表"一份尚未就绪的共享状态"。它的核心成员:
| 成员 | 作用 | 备注 |
|---|---|---|
get() | 阻塞到就绪,取走值(或重抛异常) | 调用后valid()变false,再调是 UB |
wait() | 阻塞到就绪,不取值 | 可以多次调用 |
wait_for(dur) | 最多等dur | 返回ready/timeout/deferred |
wait_until(tp) | 等到时间点 | 同上 |
valid() | 是否还关联着共享状态 | 默认构造的future是false |
share() | 转成std::shared_future | 转完之后原future不再 valid |
std::shared_future才是"可以多方读取"的版本:它的get()返回const T&(对shared_future<void>返回void),可以调用任意多次。
#include <future> #include <iostream> int compute() { return 42; } int main() { std::future<int> f = std::async(std::launch::async, compute); std::cout << "valid before get: " << std::boolalpha << f.valid() << '\n'; // true std::cout << f.get() << '\n'; // 42 std::cout << "valid after get: " << f.valid() << '\n'; // false // f.get(); // ❌ UB:valid() 为 false 时调用 get(),标准不保证任何行为 std::shared_future<int> sf = std::async(std::launch::async, compute).share(); std::cout << sf.get() << ' ' << sf.get() << '\n'; // 42 42,可以重复取 return 0; }注意.share()那行:std::async(...)返回的是临时future,share()之后它随即析构,但共享状态已转移给sf,不会被提前销毁。
wait_for是"我不想无限等"时用的。很多人把它当bool用,但std::future_status是enum class,没有到bool的隐式转换,直接写if (f.wait_for(...))会编译失败。必须显式比较:
#include <chrono> #include <future> #include <iostream> int slow_task() { return 7; } int main() { std::future<int> f = std::async(std::launch::async, slow_task); // ❌ if (f.wait_for(std::chrono::milliseconds(10))) { } // 编译错误 // ✅ 显式比较状态 if (f.wait_for(std::chrono::milliseconds(10)) == std::future_status::ready) { std::cout << "ready: " << f.get() << '\n'; } else { std::cout << "still running\n"; std::cout << f.get() << '\n'; // 后面再取也没问题,get 会继续阻塞 } return 0; }另一个必须知道的语义:带延迟启动的future在wait_for上会立刻返回std::future_status::deferred,因为它压根就没开始跑。
三、异常传播、参数传递与实战
3.1 异常怎么从工作线程"搬"回来
如果可调用对象抛出异常,std::async会把异常对象拷贝进共享状态;调用get()时,它会在调用get()的线程上被重新抛出。如果你从不调用get()(也不wait()),这个异常就被静默丢弃了。
#include <future> #include <iostream> #include <stdexcept> #include <string> int parse(const std::string& s) { if (s.empty()) throw std::invalid_argument("empty input"); return static_cast<int>(s.size()); } int main() { std::future<int> f1 = std::async(std::launch::async, parse, std::string("hello")); std::cout << f1.get() << '\n'; // 5 std::future<int> f2 = std::async(std::launch::async, parse, std::string()); try { std::cout << f2.get() << '\n'; // 在这里重抛 std::invalid_argument } catch (const std::invalid_argument& e) { std::cout << "caught: " << e.what() << '\n'; // caught: empty input } // ⚠️ 异常被丢弃:f3 从未被 get/wait,异常在 future 析构时无声消失 std::future<int> f3 = std::async(std::launch::async, parse, std::string()); return 0; }3.2 参数是"按值退化拷贝"的
和std::thread一样,std::async会把实参按值存进共享状态:数组退化为指针、函数退化为函数指针、引用被拷贝掉一份。想传引用必须包一层std::ref/std::cref,而一旦用了引用,就得回答"它能不能活到任务结束"。
#include <cstddef> #include <future> #include <iostream> #include <vector> int main() { std::vector<int> data(100, 3); // 区间求和:按 const& 接收,避免拷贝整个 vector auto sum_range = [](const std::vector<int>& v, std::size_t lo, std::size_t hi) -> long long { long long s = 0; for (std::size_t i = lo; i < hi; ++i) s += static_cast<long long>(v[i]); return s; }; // ✅ 用 std::cref 包一下,任务里拿到的才是"指向原 data 的 const 引用", // 否则 data 会被整个拷贝进共享状态 std::future<long long> left = std::async( std::launch::async, sum_range, std::cref(data), std::size_t{0}, std::size_t{50}); std::future<long long> right = std::async( std::launch::async, sum_range, std::cref(data), std::size_t{50}, std::size_t{100}); const long long total = left.get() + right.get(); std::cout << "total = " << total << '\n'; // 300 return 0; }3.3 一次"并发但不是"的翻车现场
下面这段没有计时断言,只把行为摆出来,可以自己编译运行观察输出顺序:
#include <chrono> #include <future> #include <iostream> #include <thread> int slow(int id) { std::this_thread::sleep_for(std::chrono::milliseconds(200)); return id * 2; } int main() { std::cout << "A: 丢弃返回值\n"; std::async(std::launch::async, slow, 1); // ❌ 临时 future 在此句末尾析构 → 阻塞 std::cout << "A: 这一行会在任务跑完之后才打印\n"; // 实际已经是串行 std::cout << "B: 保存 future\n"; std::future<int> f1 = std::async(std::launch::async, slow, 1); std::future<int> f2 = std::async(std::launch::async, slow, 2); std::cout << "B: 两个任务已经在并行跑了,结果是 " << f1.get() + f2.get() << '\n'; return 0; }编译命令:
g++ -std=c++17 -Wall -Wextra -pthread async_demo.cpp -o async_demo常见坑点
坑 1:不保存std::async的返回值。
// ❌ 临时 future 在语句末尾析构,析构阻塞到任务完成 —— 看起来并发,实际串行 std::async(std::launch::async, do_work); // ✅ 保存下来,需要时再取 std::future<void> f = std::async(std::launch::async, do_work); f.get();坑 2:默认启动策略导致"函数压根没执行"。实现可能选择延迟执行,此时若你从不调用get()/wait(),函数体一次都不会运行,里面的异常也一并消失。
// ❌ 依赖默认策略,可能在"没人取结果"时永远不执行 std::async(log_to_file, "boot"); // ✅ 要后台执行就显式写 async;用不到时也应显式写 deferred 表明意图 std::future<void> f = std::async(std::launch::async, log_to_file, "boot"); f.wait();坑 3:误读"async | deferred"。它不是"两个都做一遍",而是"两种都允许,实现自己挑一个":写成std::async(std::launch::async | std::launch::deferred, work)并不会同时拿到并行和惰性两样好处;想要确定的行为就只指定一种,例如std::async(std::launch::async, work)。
坑 4:get()调用两次。
std::future<int> f = std::async(std::launch::async, compute); int a = f.get(); // ❌ UB:此时 f.valid() 为 false,标准不保证任何行为 // int b = f.get(); // ✅ 要多次读取就用 shared_future std::shared_future<int> sf = std::async(std::launch::async, compute); int x = sf.get(); int y = sf.get(); // OK坑 5:把std::ref指向的局部变量带出了作用域。
#include <future> // ❌ 返回的 future 持有对 local 的引用,函数一返回 local 就没了, // 后台线程稍后写它 → 悬垂引用,UB std::future<void> bad() { int local = 0; return std::async(std::launch::async, [](int& x) { x = 1; }, std::ref(local)); } // ✅ 让任务自己持有数据的所有权(按值传参),或把生命周期提升到调用方 std::future<int> good() { return std::async(std::launch::async, [] { return 1; }); // 无外部引用 }用std::ref的判据很简单:这个被引用的对象,能不能保证活到get()/wait()返回?不确定就不要用引用。
坑 6:在future上既没get()也没wait(),异常静默丢失。
// ❌ 任务抛出的异常没人接,程序静默继续 std::async(std::launch::async, might_throw); // ✅ 至少 get 一次,让异常被重新抛出 std::future<void> f = std::async(std::launch::async, might_throw); f.get();坑 7:把std::async当线程池用。标准没有"复用线程"的语义,libstdc++ 的实现就是每次launch::async新建一个std::thread;高频调用时线程创建销毁的开销会占大头。复用与否属于实现细节,但"不要假定它复用线程"是可移植的结论。
// ❌ 一万次小任务 → 一万次可能的线程创建 for (int i = 0; i < 10000; ++i) { futs.push_back(std::async(std::launch::async, tiny_task, i)); } // ✅ 要么改用固定大小的线程池,要么把任务合并成少量较大的块坑 8:把future当作"保证任务在后台跑"的凭据。future只保证"结果可取",不保证"现在正在跑";能观察的只有wait_for返回的状态。
auto f = std::async(std::launch::deferred, work); // 注意这里是 deferred // f.wait_for(...) 会立刻返回 std::future_status::deferred // 这意味着"任务根本没开始",而不是"任务还没完成"总结
| 要点 | 结论 |
|---|---|
| 返回类型 | std::future<R>,R是调用结果类型(C++17 起按std::invoke_result推导) |
| 启动策略 | async立即在新线程跑;deferred惰性、在get()/wait()的线程上跑;默认是两者按位或 |
| 默认策略的风险 | 行为由实现选择,可能永不执行、可能丢异常、可能阻塞 |
future析构 | 对launch::async且未就绪的任务会阻塞(C++11 起的标准要求)——所以别丢弃返回值 |
get() | 只能调一次,调完valid()变false,再调是 UB;多方读取用shared_future |
wait_for | 返回std::future_status(enum class,不能当bool用);延迟任务立刻返回deferred |
| 异常 | 存入共享状态,在get()处重抛;从不get()就静默丢弃 |
| 参数传递 | 与std::thread一致,按值退化拷贝;要引用用std::ref/std::cref |
| 线程复用 | 标准不保证;libstdc++ 的实现是每次新建线程(实现细节) |
三句话收尾:显式写清楚启动策略、永远保存future并在确定的时点get()(既避免析构阻塞把并发变成串行,也避免异常被吞掉)、用std::ref之前先回答"它活得够久吗"。做到这三点,std::async就是非常好用的小工具;把它当线程池用,则是它设计上不打算承担的事。