ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

std::async、future、promise:异步任务的返回值、异常与三个经典坑

2026/10/7 2:41:45 拓冰建站 浏览量
std::async、future、promise:异步任务的返回值、异常与三个经典坑 std::thread只解决了一半问题它能把活扔到另一个线程但拿不回返回值也接不住异常。std::asyncstd::future才是标准库给出的完整答案结果和异常都装在一个共享状态shared state里由调用方按自己的节奏来取。但这套工具埋了三个几乎人人踩过的坑默认启动策略不确定、get()只能调一次、future 析构会阻塞。这篇把它们一次讲透。1. 引子std::thread的两个硬伤// 片段std::thread 拿不到返回值也接不住异常intcompute();// 假设这是个有返回值的耗时函数// 硬伤 ①要拿返回值只能靠「出参 引用捕获」代码立刻变啰嗦intresult0;std::threadt([result]{resultcompute();});t.join();// 硬伤 ②线程函数里漏出异常 → 直接 std::terminate没有栈展开、没有 catchstd::threadt2([]{throwstd::runtime_error(boom);});// 反例不要这么写第二个硬伤最要命std::thread的入口函数如果抛出异常标准规定直接调用std::terminate你在外面写多少个try/catch都拦不住异常无法跨线程边界传播。所以两种场景必须换工具需求std::threadstd::asyncstd::future拿到返回值靠出参 引用捕获手工同步future.get()直接返回拿到异常不可能直接std::terminate异常存进共享状态get()时重新抛出知道任务是否完成只能join()硬等wait_for/wait_until带超时检查一个任务的结果给多处用要自己做一层同步共享状态天生可多份future共享shared_future控制「什么时候开线程」立刻开由启动策略决定见下节官方文档std::async — cppreference · std::future — cppreference2.std::async的两种启动策略差别大到不能不管std::async的第一个参数是启动策略launch policy只有两个合法取值而它们的语义完全不同std::async(std::launch::async, f) std::async(std::launch::deferred, f) ───────────────────────────────── ─────────────────────────────────────── main 新线程 main │ │ │ │ async 返回 ───────────►│ 立刻开跑 │ async 返回什么都没发生 │ │ │ │ 继续干别的活… │ 计算中… │ 继续干别的活…f 一直没被调用 │ │ │ │ fut.get() ◄────────────┘ 取结果 │ fut.get() ──► 此刻才调用 f │ │ 而且就在 main 线程上 ↓ ↓ 两者都会「等」到结果但「等」的含义完全不同 async —— 结果早算好了get 只是取回来 deferred —— 计算从 get 那一刻才开始懒执行策略是否新开线程何时执行与调用线程并行典型用途std::launch::async是强制新建线程async调用时立刻开始是真正想榨多核性能std::launch::deferred否第一次get()/wait()时否在调用get()的线程上同步跑想做「延迟计算」/ 任务可能用不上就别算不写默认由实现决定由实现决定由实现决定别这么用deferred最反直觉的一点它不是「排队等线程池有空」而是彻底不并发。你get()的那一刻函数就在当前线程上跑起来了如果这个函数耗时 3 秒你的调用线程就卡 3 秒。// launch_policy.cpp — 编译: g -stdc17 -Wall -O2 -pthread launch_policy.cpp -o lp#includecstdio#includefuture#includethreadintmain(){conststd::thread::id callerstd::this_thread::get_id();// ① launch::async —— 强制新建线程std::futurestd::thread::ideagerstd::async(std::launch::async,[]{returnstd::this_thread::get_id();});std::printf(launch::async 在新线程执行 %d\n,static_castint(eager.get()!caller));// ② launch::deferred —— get() 的时候才跑而且就在当前线程std::futurestd::thread::idlazystd::async(std::launch::deferred,[]{returnstd::this_thread::get_id();});std::printf(launch::deferred 在当前线程执行 %d\n,static_castint(lazy.get()caller));// ③ 不写策略 async | deferred具体挑哪个看实现std::futurestd::thread::iddfltstd::async([]{returnstd::this_thread::get_id();});std::printf(默认策略libstdc 实测开了新线程 %d\n,static_castint(dflt.get()!caller));}launch::async 在新线程执行 1 launch::deferred 在当前线程执行 1 默认策略libstdc 实测开了新线程 1官方文档std::launch — cppreference只有async和deferred两个可移植取值async | deferred是默认组合3. 默认策略是个坑它由实现决定std::async(f)不带策略时等同于传std::launch::async | std::launch::deferred标准明确允许实现自己挑一个而且标准里那句「实现可以选择」意味着同一段代码换编译器或换机器就可能换行为。这为什么危险因为两种策略对线程局部变量、线程 ID、thread_local初始化、以及是否真的并发的影响是天差地别的默认策略下同一段代码可能掉进两条完全不同的路径 std::async(f) ──┬── 实现挑 async → f 在新线程跑get() 只是取结果 真并发 │ └── 实现挑 deferred → f 在 get() 的线程跑get() 前什么都不做 假并发而且 get() 会卡住自己一个典型后果f里如果访问了thread_local变量两种策略下看到的是不同的实例再比如你以为提前把耗时任务发出去了其实一点没开始等到get()才一口气做完。所以规则很简单只要你在意确定性就把策略明写出来。想要真并发就std::launch::async想做懒执行就std::launch::deferred不要留空。官方文档C Core Guidelines · 并发章节 CP.6 附近这条链接是并发章节总入口讲「别让调度策略听天由命」时值得回看4.future是「消费型」的get()只能调一次future和「读一个变量」最大的不同它是一次性的。共享状态里的值被get()取走移动之后future就失效了成员函数作用会不会消费掉共享状态对deferred任务的影响get()等待完成 → 返回结果任务抛过异常就在这里重抛会只能调一次会启动任务在当前线程跑wait()只等完成不取值不会会启动任务wait_for(d)最多等d返回future_status不会会启动任务wait_until(tp)等到某个时间点返回future_status不会会启动任务对已经 ready的 future 再调get()行为是未定义的libstdc 会抛std::future_errorstd::future_errc::no_state对已被移动走的 future 调get()同样出错。所以「一个结果给多处用」的正确做法是先转成std::shared_future// 片段一次结果多处消费std::shared_futureintsharedstd::async(std::launch::async,[]{return42;}).share();std::printf(%d %d\n,shared.get(),shared.get());// shared_future::get() 可以重复调wait_for的返回值std::future_status有三个取值含义别记混future_status含义下一步该做什么ready已经完成结果可取放心get()timeout超时且还没完成可以继续等或走降级逻辑deferred任务被deferred策略挂起了不调get()/wait()就永远不会跑要么get()要么放弃官方文档std::future::get — cppreference · std::future_status — cppreference5.promisefuture手动搭一条通道std::async是「调用方发起、库帮你建通道」std::promise是你自己建通道promise在一侧写future在另一侧读中间由标准库保证同步。它适合「结果的产生时机不受调用方控制」的场景比如回调、事件、以及跨线程传异常。// promise_channel.cpp — 编译: g -stdc17 -Wall -O2 -pthread promise_channel.cpp -o pc#includecstdio#includeexception#includefuture#includestdexcept#includethreadintmain(){std::promiseintchannel;std::futureintanswerchannel.get_future();// 先把 future 取出来std::threadproducer([channel]{try{throwstd::runtime_error(数据源不可用);}catch(...){channel.set_exception(std::current_exception());// 异常装进共享状态}});producer.join();try{std::printf(answer.get() %d\n,answer.get());// 走不到这里里面装的是异常}catch(conststd::runtime_errore){std::printf(promise 传出来的异常: %s\n,e.what());}}promise 传出来的异常: 数据源不可用promise的三板斧对应三种结局set_value(v)让get()正常返回set_exception(ep)让get()重新抛出set_value_at_thread_exit(v)则把「就绪」推迟到线程退出时才发生它保证get()返回时写方那边的局部对象已经析构完毕适合需要「结果可见 清理已完成」的场景。三者在同一个promise上只能有一个生效重复设置会抛std::future_error。官方文档std::promise — cppreferenceset_value/set_exception/set_value_at_thread_exit的完整列表· std::shared_future — cppreference6. 异常怎么「跨线程」传出来线程边界传不了异常但共享状态能装这就是std::async传异常的机制任务在哪个线程抛不重要异常被捕获后存进共享状态等你get()时在调用线程重新抛出栈展开、catch都发生在你的线程上。这件事的意义是try/catch终于能包围一个异步任务了。// async_error.cpp — 编译: g -stdc17 -Wall -O2 -pthread async_error.cpp -o ae#includechrono#includecstdio#includefuture#includestdexcept#includethreadintmain(){// ① 任务里的异常存在共享状态里get() 时在调用线程重新抛出std::futureintbadstd::async(std::launch::async,[]()-int{throwstd::out_of_range(索引越界);});try{std::printf(这一行拿不到值: bad.get() %d\n,bad.get());}catch(conststd::out_of_rangee){// 原始类型完整保留没有被包装成别的类型std::printf(catch 到 std::out_of_range: %s\n,e.what());}// ② wait_for 只查状态、不取值适合做超时降级std::futureintslowstd::async(std::launch::async,[]{std::this_thread::sleep_for(std::chrono::milliseconds(150));return7;});constautoearlyslow.wait_for(std::chrono::milliseconds(10));std::printf(等 10 毫秒就完成 %d\n,static_castint(earlystd::future_status::ready));constautolateslow.wait_for(std::chrono::seconds(5));std::printf(等 5 秒后完成 %d, 值 %d\n,static_castint(latestd::future_status::ready),slow.get());}catch 到 std::out_of_range: 索引越界 等 10 毫秒就完成 0 等 5 秒后完成 1, 值 7注意第二段wait_for返回timeout之后共享状态还在你依然可以再等一次并get()。只有get()会消费它。7. 最隐蔽的坑async返回的 future 析构会阻塞「即发即忘」fire and forget是很多人对std::async的第一印象不理会返回的 future任务扔出去就完事。错。标准规定std::launch::async启动的共享状态最后一个持有它的future析构时会阻塞直到任务执行完毕等价于一次wait()。也就是说上面那句「即发即忘」其实是同步的// async_forget.cpp — 编译: g -stdc17 -Wall -O2 -pthread async_forget.cpp -o af#includeatomic#includechrono#includecstdio#includefuture#includethreadintmain(){std::atomicboolfinished{false};// 任务完成标志{std::futurevoidfutstd::async(std::launch::async,[finished]{std::this_thread::sleep_for(std::chrono::milliseconds(200));finished.store(true,std::memory_order_relaxed);});std::printf(作用域结束前, finished %d\n,static_castint(finished.load()));// 任务还在睡 → 0}// ← 这一行是 fut 的析构它会一直等到任务跑完才返回std::printf(future 析构之后, finished %d\n,static_castint(finished.load()));// 1说明析构真的等了}作用域结束前, finished 0 future 析构之后, finished 1两行输出之间隔了大约 200 毫秒这就是「析构阻塞」的直接证据。为什么会这么设计因为标准库想保证你永远不会在任务还在访问那些对象的时候把它们析构掉。代价是「即发即忘」写不出来。真要做即发即忘有两条正路std::thread(...).detach()但异常就没人接得住了、而且线程创建开销全摊在你身上或者扔进《手写一个线程池任务队列、工作线程与停止顺序的三个设计点》。顺带说一句deferred的 future 析构不会阻塞任务压根不会被执行。官方文档std::future::~future — cppreference明确写了「可能阻塞直到共享状态就绪包括launch::async的情况」8. 完整示例packaged_task和async怎么分工三者的区别只有一句话async是「帮我跑」packaged_task是「包装成可调用的结果工厂」promise是「我手动控制何时置值」。工具谁决定何时执行返回值能不能塞进别的线程 / 线程池备注std::async启动策略或实现直接拿到future不适用它自己管线程最方便注意析构阻塞std::packaged_task你自己调用它先从get_future()拿能可移动、可拷贝不成立线程池的标准零件std::promise你手动set_value先从get_future()拿不适用只是个容器最灵活也最容易忘记置值packaged_task的典型用法是「先取 future再把任务丢给别人执行」// async_sum.cpp — 编译: g -stdc17 -Wall -O2 -pthread async_sum.cpp -o as#includecstddef#includecstdio#includefuture#includenumeric#includevectorintmain(){constexprstd::size_t kTotal1000000;constexprstd::size_t kWorkers4;constexprlonglongkExpected500000500000LL;// 12…1000000用常量自己核对std::vectorintdata(kTotal);std::iota(data.begin(),data.end(),1);conststd::size_t chunkkTotal/kWorkers;std::vectorstd::futurelonglongfutures;futures.reserve(kWorkers);for(std::size_t w0;wkWorkers;w){futures.push_back(std::async(std::launch::async,[data,w,chunk]{constautofirstdata.begin()static_caststd::ptrdiff_t(w*chunk);constautolastfirststatic_caststd::ptrdiff_t(chunk);returnstd::accumulate(first,last,0LL);}));}longlongtotal0;for(autofut:futures){totalfut.get();// 每个 future 只 get 一次}std::printf(async 并发求和 %lld与常量一致 %d\n,total,static_castint(totalkExpected));// packaged_task可调用对象 一个提前取好的 futurestd::packaged_tasklonglong(int,int)task([](inta,intb){returnstatic_castlonglong(a)b;});std::futurelonglongtask_futuretask.get_future();task(20,22);// 在当前线程手动调用也完全可以搬去别的线程std::printf(packaged_task 结果 %lld\n,task_future.get());}async 并发求和 500000500000与常量一致 1 packaged_task 结果 42四个线程各算 25 万个数的和再在主线程汇总不管四个线程谁先跑完总和恒定这就是「只打印确定性结果」的写法求和与顺序无关所以输出可以写死。而packaged_task那段展示了它和async的分工它自己不开线程谁调用它就在谁的线程上跑。9. 延伸阅读std::async — cppreference —— 默认策略那句「实现可以选择」的原文就在这里值得逐字读一遍std::launch — cppreference —— 两个策略的准确定义以及实现为什么不能加第三个取值std::future — cppreference ——get/wait/wait_for/valid全家的语义重点看get的「只能调用一次」std::future::~future — cppreference —— 「析构可能阻塞」这条规定的出处写「即发即忘」前必看std::promise — cppreference —— 三个set_*的区别与std::future_error的触发条件std::packaged_task — cppreference —— 线程池里那个「标准零件」关注它为什么只可移动C Core Guidelines · 并发章节 —— CP.8别用volatile做同步、CP.20~CP.22锁用 RAII等规则的出处本知识库内的相关篇目《std::thread 入门启动、join、detach 与生命周期》 —— std::thread 析构前必须 join 或 detach《手写一个线程池任务队列、工作线程与停止顺序的三个设计点》 —— 每个任务起一个 std::thread 的写法在任务短、数量多的时候会把时间全花在创建和销毁线程上。《condition_variable 生产者消费者完整实现虚假唤醒与丢失唤醒一次讲透》 —— 条件变量是线程间「等到条件成立」的标准工具但两个坑几乎人人踩过10. 一句话总结std::async把「结果 异常」一起装进共享状态std::future负责取launch::async立刻开新线程、launch::deferred拖到get()才在当前线程跑默认策略是两者按位或、由实现决定所以策略必须显式写get()是一次性消费想多次消费就转shared_future任务里的异常在get()时于调用线程重新抛出promise让你手动控制何时置值、何时置异常packaged_task把可调用对象包成「自带 future 的零件」交给线程池最后记住最坑的一条async返回的 future 析构时会阻塞到任务结束所谓即发即忘其实是同步的。