资讯动态

C++异步编程实践:std::async、future与promise的协同与避坑

发布时间:2026/10/24 5:52:20 来源:尧图企业网站定制
1. 从一次卡死的界面说起为什么需要异步任务先讲一个我早年间遇到的真实场景。当时在做某个桌面端图像处理Demo功能很单纯用户点一个按钮程序对一张大图做高斯模糊然后刷新界面显示结果。第一版代码直来直去点击事件里直接调用处理函数图片处理耗时大约2到3秒。跑起来之后界面直接冻结鼠标转圈拖动窗口都没反应。用户反馈说程序死了其实不是死了是主线程被长任务堵住了。当时的第一反应是加个进度条让用户知道它还在干。结果进度条自己也卡住不动了因为进度刷新逻辑跟处理逻辑在同一个线程里处理函数不返回界面根本没机会重绘。后来换成把图片处理丢到后台线程再用回调通知界面刷新界面确实不卡了但回调嵌套越来越多——一个任务拆成三步就要写三层回调参数传递和错误处理变得乱七八糟。再后来换了台多核机器发现明明有8个核但程序只用一个核在跑任务队列里的其他任务都在排队等。这个经历基本上涵盖了C异步编程要解决的三个核心问题第一避免长任务阻塞调用线程第二把异步结果以合理的方式传递回来而不是靠层层回调第三利用多核硬件真正并行执行多个任务。std::async、std::future、std::promise这三个组件恰好分别对应了发起异步任务获取异步结果手动设置异步结果这三件事。这篇文章不打算像教科书那样逐个API罗列而是想从一个实践者的角度把这几个组件怎么配合、什么时候用哪个、有哪些坑一次性讲透。适合读这篇文章的人有两种一种是你已经写过一点多线程程序但用的还是std::thread加全局变量的老办法想看看标准库提供了什么更优雅的工具另一种是你已经看过std::async的文档知道它大概能干什么但碰到实际问题时不确定怎么组合使用。下面从最底层的机制开始讲起理解了机制后面无论怎么组合都不会跑偏。2.std::future与std::promise一种单向值传递的约定2.1 它们到底在传递什么很多人第一次看std::promise的示例代码都会困惑明明一个全局变量就能搞定的事为什么要搞个promise出来我当时的想法也是这样。直到我意识到std::promise和std::future真正解决的并不是传值这个动作本身而是一个线程需要等待另一个线程的计算结果但不知道结果什么时候准备好这个问题。可以这样理解std::promise是生产者手里的一张提货单std::future是消费者手里对应的另一张提货单。生产者往promise里写入值消费者从future里读取值。如果消费者先到future的get()会阻塞等待如果生产者先写完消费者来了直接拿走。两张单子共享一个底层状态这个状态内部有值存储区、就绪标志和等待线程列表。所以它本质上是一种一对一的线程间同步通信机制只是通信的内容是一个值而已。这个机制和C11之前的做法差别很大。以前如果线程A要等线程B的结果通常做法是定义一个全局或成员变量加一个互斥锁再加一个条件变量。线程A锁住变量检查值没值就wait线程B算完把值写进去然后notify。这套写法本身没错但代码很容易出错——忘加锁、条件变量假唤醒、通知丢失任何一个问题都会导致死锁或脏读。std::promise把它封装成了一个线程安全的单一操作整个同步逻辑只剩下写值和读值两步。2.2 手动设置值的完整代码示例来看一个最基础的例子演示promise如何在不同线程间传递一个整数结果#include iostream #include thread #include future #include chrono int main() { std::promiseint prom; std::futureint fut prom.get_future(); std::thread worker([prom]() { // 模拟一个耗时计算 std::this_thread::sleep_for(std::chrono::seconds(2)); int result 42; prom.set_value(result); }); // 主线程在这里会阻塞等待 int value fut.get(); std::cout 得到结果: value std::endl; worker.join(); return 0; }这里最关键的一行是std::futureint fut prom.get_future();。一个promise只能调用一次get_future()也就是说一个promise实例只能被一个future消费。如果某个设计里多个线程需要等待同一个结果就必须改用std::shared_future这个我在后面第5章会专门讲。需要注意future::get()只能调用一次。第二次调用会抛出std::future_error错误码是future_errc::future_already_retrieved。这是新手最容易踩的坑——第一次调用get()拿到了值后面某个地方想再拿一次结果程序崩了。如果你真的需要多次读取结果方案是先用局部变量保存第一次get()的结果后续从变量里取数或者换成shared_future。2.3 异常也是值的一部分std::promise不只是传正常结果还能传异常。如果生产者内部出错了可以调用set_exception把异常对象塞进去消费者调用get()时异常会原样抛出来。#include iostream #include thread #include future #include stdexcept int main() { std::promiseint prom; std::futureint fut prom.get_future(); std::thread worker([prom]() { try { throw std::runtime_error(计算过程中出现错误); } catch (...) { prom.set_exception(std::current_exception()); } }); try { int value fut.get(); std::cout 得到结果: value std::endl; } catch (const std::exception e) { std::cout 捕获异常: e.what() std::endl; } worker.join(); return 0; }我实际项目中很喜欢这个特性因为传统回调方案里错误处理是最难统一的部分每个回调都要单独检查错误码或异常。promise把成功值和失败原因统一成一个信道消费者只用一次get()就同时处理了两种情况代码路径清晰很多。注意set_value和set_exception都只能调用一次。如果promise在析构时既没有被设置值也没有被设置异常future端会收到一个std::future_error错误码是broken_promise。broken_promise这个细节很容易被忽略。它意味着一旦你创建了promise并分发了future你就承担了必须给个结果的责任。如果生产者线程中途放弃了或者压根没启动成功消费者那边会一直等到地老天荒或者收到broken_promise。在工程上这个机制其实帮我们暴露了不少忘了给结果的bug比静默卡死好排查得多。3.std::async标准库对异步执行的一层封装3.1 为什么有thread了还需要async有了std::thread理论上你可以手动创建线程、在线程函数里构造promise、设置值、再join完全没问题。但每写一个异步任务都要重复这套模板代码量不小而且容易漏——忘了捕获异常、忘了处理join、线程对象析构时还在运行导致std::terminate。std::async干的事就是把这套模板全部封装好你只管给它一个可调用对象和参数它返回一个future任务在后台执行结果通过future取异常同样通过future传你不用手工管理线程生命周期。从设计意图上看std::async是高层异步工具std::thread是底层线程工具。如果你需要的是发起一个一次性任务并获取结果默认用std::async如果你需要精细控制线程的生命周期、绑定到特定线程、或者线程需要在程序生命周期内持续运行那才用std::thread配合promise手工管理。3.2 代码怎么写最基本的三行一个典型用法长这样#include iostream #include future #include chrono int fetch_data_from_disk() { std::this_thread::sleep_for(std::chrono::milliseconds(800)); return 1024; } int main() { // 异步执行任务 std::futureint fut std::async(std::launch::async, fetch_data_from_disk); // 主线程可以继续做别的事情 std::cout 主线程正在做其他工作... std::endl; // 需要结果时再取 int data fut.get(); std::cout 读取到数据: data std::endl; return 0; }这段代码背后的行为很容易被低估。std::async的第一个参数是启动策略分三种情况std::launch::async表示必须创建新线程执行std::launch::deferred表示延迟执行直到调用get()或wait()才在当前线程里执行两者用位或|连接表示由实现决定。如果完全不传策略等价于std::launch::async | std::launch::deferred底层库可以自由选择。我在实际使用中发现很多开发者在没指定策略时误以为一定起新线程这是个大坑。请看下一节的实测分析。3.3 async的启动策略没写launch参数的后果写一段测试代码来观察不同策略的行为差异#include iostream #include future #include thread int main() { std::futureint fut1 std::async(std::launch::async, []() { std::cout async策略: 线程ID std::this_thread::get_id() std::endl; return 1; }); std::futureint fut2 std::async(std::launch::deferred, []() { std::cout deferred策略: 线程ID std::this_thread::get_id() std::endl; return 2; }); std::futureint fut3 std::async([]() { std::cout 默认策略: 线程ID std::this_thread::get_id() std::endl; return 3; }); // 先等到 fut1 std::cout fut1结果 fut1.get() std::endl; std::cout fut2结果 fut2.get() std::endl; std::cout fut3结果 fut3.get() std::endl; return 0; }在大多数标准库实现下输出会是这样async策略的任务在独立线程执行打印的线程ID和主线程不同deferred策略的任务在调用get()时才执行线程ID和主线程一样默认策略的行为不固定图书馆一般会选async但不能依赖这一点。这个测试揭示了一个工程教训如果你确实希望任务异步执行显式写上std::launch::async别偷懒省略。省一行参数的代价是程序行为可能在不同标准库版本之间发生变化。反过来说如果你希望任务惰性执行——比如任务结果可能根本用不上用std::launch::deferred可以避免创建线程的开销这在任务昂贵但调用结果可选的场景下很实用。3.4 future析构问题是async使用中的头号陷阱这个是所有async用法里最隐蔽、最容易出问题的地方。看下面这段代码std::futureint get_result() { std::futureint fut std::async(std::launch::async, []() { std::this_thread::sleep_for(std::chrono::seconds(5)); return 99; }); return fut; // 移动返回 }注意这里future通过返回值传递析构发生在外部接收之后的某个时刻一切正常。但如果你写出下面这种代码void run_task() { std::async(std::launch::async, []() { std::this_thread::sleep_for(std::chrono::seconds(3)); std::cout 任务完成 std::endl; }); // 这里没有接收future临时future立即析构 // 析构时会阻塞等待任务执行完毕 std::cout run_task返回 std::endl; }std::async返回的临时future在行尾析构。标准规定当future是std::async返回的且析构时任务尚未完成析构函数会阻塞直到任务执行完。也就是说这段代码虽然名义上是异步的但调用run_task的线程实际上会卡3秒等异步任务干完活才返回。很多人第一次看到这个行为都以为是死锁其实这是标准库设计的有意而为——确保你不再持有future时异步任务不会在后台无人监管地泄漏掉。所以请记住这个规则如果你不关心异步任务的返回值只希望它后台跑完就行请显式用一个变量接收future并且这个变量的作用域要覆盖你希望任务执行的时间范围。或者你在调用async前就考虑清楚这个future后续一定会被get()或wait()消费。最稳妥的写法是把future存到一个容器里统一管理生命周期。4. 三者协同的完整实战一个可复用的异步任务调度模式4.1 场景设定批量数据抓取与汇总这一章用一个综合例子来演示三者的协同。场景是程序需要从多个数据源模拟为多个URL拉取数据每个数据源耗时不同然后汇总所有结果最终返回一个总结果。这个场景在真实项目里非常常见——批量接口调用、批量日志分析、批量文件处理本质上都是一个模型。用纯粹std::async做这件事是可以的问题是如果批量任务中间还需要动态汇报进度部分任务失败后重试把多个结果增量通知给不同模块纯async就不够灵活了。这时候std::promise就派上用场了——它允许你在任务的任意时刻主动填入结果而不是非要等到函数返回。把两者组合起来async负责跑线程promise负责在任务中途精确传值future负责所有消费者统一收数。4.2 设计思路async管执行promise管传值我采用的模式是主程序对每个数据源创建一个std::promise然后把promise移动进由std::async启动的任务里。每个任务内部完成后调用set_value。主程序把所有这些future收集到一个容器里统一等待和取结果。为了能让进度通知也走promise传出来我额外创建一个进度future在任务内部每次处理完一个子项就更新它。这个模式的好处是任务的边界变得非常清晰std::async只管在哪个线程跑promise和future管数据怎么流动。如果你新加入一个数据源只需要创建一个promise、一个future、一个async任务三行代码不需要改任何共享逻辑。4.3 具体实现代码下面是一个可编译运行的完整示例#include iostream #include future #include vector #include string #include chrono #include thread #include random int simulate_fetch(const std::string source, int delay_ms) { std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); // 模拟返回数据大小 return static_castint(source.size()) * 10 delay_ms / 100; } int main() { std::vectorstd::string sources {source_a, source_b, source_c, source_d}; std::vectorint delays {300, 700, 500, 900}; std::vectorstd::promiseint promises; std::vectorstd::futureint futures; // 建立一一对应的promise和future for (size_t i 0; i sources.size(); i) { std::promiseint prom; futures.push_back(prom.get_future()); promises.push_back(std::move(prom)); } std::vectorstd::futurevoid task_futures; for (size_t i 0; i sources.size(); i) { // 把promise移动进async任务 task_futures.push_back(std::async(std::launch::async, [i, promises, sources, delays]() { int result simulate_fetch(sources[i], delays[i]); promises[i].set_value(result); })); } int total 0; for (auto fut : futures) { total fut.get(); } // 等待所有任务线程完全收尾 for (auto t : task_futures) { t.get(); } std::cout 汇总结果: total std::endl; return 0; }这段代码里需要注意的地方promise必须存放在稳定的容器里因为lambda是引用了promises[i]如果vector扩容导致promise移动引用就失效了。所以我预先reserve或者让容器大小固定。当你的promise数量在运行前就确定时先一次性建好全部promise和future再启动全部async任务这个顺序能天然规避移动问题。如果你要动态添加数据源可以用std::deque这种不移动元素的容器或者改用智能指针持有promise。4.4 中途汇报进度promise真正的独门优势现在把需求升级任务运行过程中主线程需要知道当前完成了几个子任务。用std::async直接返回一个int的方式做不到这一点因为结果只能在任务结束时返回一趟需要在任务内部把进度推出来。用promise最容易。思路是这样额外定义一个进度promise其future由主线程持有。在任务内部每完成一个子项就重新创建一个promise并set_value。主线程用一个循环多次get()。但前面说过future只能get一次所以正确的实现方式是配合std::shared_future#include iostream #include future #include thread #include atomic #include chrono int main() { std::promiseint progress_promise; std::shared_futureint progress_shared progress_promise.get_future(); std::atomicint current_progress{0}; std::thread worker([]() { for (int i 1; i 5; i) { std::this_thread::sleep_for(std::chrono::milliseconds(500)); current_progress i; progress_promise.set_value(i); // 不能在同一promise上再次set_value所以需要新建promise if (i 5) { std::promiseint new_progress; // 这里要重新给主线程一个future需要主线程配合先略过 } } }); worker.join(); return 0; }这段代码其实有一个明显的问题一个promise只能set一次值。真正可复用的进度汇报模式是每次创建新promise并把新的future通过队列传给主线程工程上通常配合channel或queue实现。这里点到为止说明promise适合单次状态变更的通知连续进度更新更适合std::atomic、条件变量或channel。别只盯着多线程这几个API不同粒度的问题有不同的工具。5. 进阶实操std::packaged_task、shared_future与真实项目中的选择5.1 packaged_task是什么和async什么关系std::packaged_task是介于promise和async之间的一个组件。它把一个可调用对象的返回值自动包装成future相当于async做了但你没看到的内部步骤。看名字packaged的task就是打包好的任务。使用方式是创建一个packaged_task对象它就是可调用对象当你调用它的时候返回值会被存入内部promise然后通过get_future()取出来。#include iostream #include future int main() { std::packaged_taskint(int, int) task([](int a, int b) { return a b; }); std::futureint fut task.get_future(); // 手动执行可以在任意线程里调用task(...) task(2, 3); std::cout 结果: fut.get() std::endl; return 0; }std::packaged_task最常见的价值是你可以把任务分配给一个不归标准库管的线程池或者自己控制调用时机。它比promise更好用因为你不需要手动写计算完再set_value返回值天然变成future。如果需要在两个项目模块之间传递任务packaged_task也更容易被封装。5.2 shared_future解决多消费者等同一个结果的问题前面提到普通future只能get一次。假如你有三个worker线程都需要等待同一个数据源的结果然后并行做各自的下游处理普通future做不到——只有第一个get()能拿到值剩下两个会抛异常。解法是std::shared_future#include iostream #include future #include thread #include vector int main() { std::promiseint prom; std::shared_futureint shared prom.get_future(); std::vectorstd::thread workers; for (int i 0; i 3; i) { workers.emplace_back([i, shared]() { int value shared.get(); // 多个线程可各自get std::cout 线程 i 拿到值: value std::endl; }); } std::this_thread::sleep_for(std::chrono::milliseconds(500)); prom.set_value(100); for (auto w : workers) { w.join(); } return 0; }注意lambda按值捕获shared_future因为它是可以拷贝的。如果一个future既要传给异步任务又要在主线程中等待把它转成shared_future是一种方便的选择。std::future的share()方法可以生成一个shared_future之后原来的future不能再get()。5.3 真实项目里怎么选型对比一下四条路线。第一条任务简单、一次性、不关心进度直接用std::async加默认策略三行搞定。第二条任务需要在特定线程运行、需要中间手动传值用std::promise加std::thread。第三条任务需要投递到线程池但返回值还是future的形式用std::packaged_task。第四条多个消费者等同一个结果用std::promise配合std::shared_future。场景推荐组件原因一次性异步计算并取结果std::async代码最简自动管理线程生命周期在指定线程中执行任务并传值std::promisestd::thread手动控制线程精确定位传值时机任务投递到自定义线程池且要futurestd::packaged_task保留future语义不依赖async的调度多线程等同一结果std::promisestd::shared_futurefuture可复制多消费者各取一份上面表格里说线程池我的实际经验是现代C项目里线程池通常通过std::packaged_task来封装任务体因为线程池本身不关心任务返回值具体怎么处理只要求给我一个可以调用的东西。packaged_task正好是可调用 结果future的组合体。6. 挖坑实录我实际踩过的异常、死锁与性能问题6.1 future被析构导致的伪死锁有一次排查某个服务程序卡住的问题日志显示某个请求处理了10秒还没返回。用调试器挂上去看到一个线程阻塞在等待一个future的结果。表面上看是死锁其实不是——代码在某个分支里用std::async启动了任务但没接收返回的future临时future在语句末尾析构析构阻塞等待任务完成。而那个任务偏偏内部还要等另一个互斥锁持锁的线程恰恰是这个求情的调用线程于是真的卡在一起了。这类问题排查起来很痛苦因为堆栈显示的地点是std::async内部跟业务逻辑没有直接关系。后来我在团队里定了条规矩凡是std::async调用的返回值一律接住要么存成员变量要么明确消费禁止写裸调用的std::async(...)。这条规矩执行后这类问题基本绝迹。6.2 异常被吞掉的问题promise和async虽能传异常但也可能被误解。常见错误是把set_exception设为空std::exception_ptr()这样future端get()会抛出一个空指针异常类型串错排查半天。更常见的坑是任务内若没捕获异常又直接调用set_value那么异常会在任务内部被终止掉future端永远拿不到结果。一个修复方案的模板std::promiseint prom; auto fut prom.get_future(); std::async(std::launch::async, [prom]() { try { int result do_work(); prom.set_value(result); } catch (...) { prom.set_exception(std::current_exception()); } }).get(); // 这里等待任务完成确保promise被设置我后来更推荐直接让任务函数本身write到promise异常由async的机制自动包装少一个手动捕获的环节——但如果你的任务是纯thread而非async上面的try/catch模板就是必需品。6.3 性能陷阱async不承诺立即并发还有一个性能上的误区是默认策略下如果连续发100个async任务你以为它们会并行跑在100个线程上实际完全不是。std::async虽然可能启用线程池但线程数量是有限的任务会排队。而且如果处理器核数只有8个并行度最多8另外92个任务在排队等待。在需要大量并行任务的场景下正确的做法是自己构建线程池任务量才是并行度。async适合少量任务、每个都较重要、低频发起的场景。从另一个角度看std::async创建的线程开销也会积累。如果每次请求进来都发起一个async任务请求量大时线程数会爆炸。我的经验标准是每秒如果超过几十个异步任务就要考虑线程池接管只有低频、长耗时的任务才适合直接用async。6.4 死锁的典型场景主线程等待future而任务需要主线程干活还有一种写法极其引发死锁用普通future阻塞等待结果但执行任务的那个线程内部又需要调用主线程执行的功能比如某个回调必须由主线程去触发。std::async默认策略下任务可能根本不创建新线程而是延迟到get()时在当前线程执行那么get()就会一直等死锁。这类问题的根因在于对策略的不明确预期。所以我在关键任务上总是显式指定std::launch::async并且在文档里写明本任务必须独立线程执行禁止deferred。6.5 经验小结几条看了就能用的规则创建future时明确消费次数单次消费用std::future多消费者用std::shared_future。每个promise只能写一次每个future只能读一次违反就是异常。std::async返回的future析构会阻塞等待任务完成不想要这个行为就接住返回值。显式指定启动策略别依赖默认行为。高频任务用std::packaged_task加线程池不要用std::async硬扛。生产者和消费者如果不在同一执行流上务必用try/catch包装set_value以免异常丢失。7. 写在最后的实践心得今天花了大篇幅把这些组件的机制讲透本质上是因为我吃过太多文档都会读、代码不会写的亏。std::async、std::future、std::promise这三个组件单独看都不难难的是组合在一起时它们各自承担什么职责、生命周期怎么管理、异常怎么流转。我个人的体会是先不要追求用上所有高级特性而是把promise传值、future取值、async起线程这个最小组合练到滚瓜烂熟然后遇到具体问题再逐步引入packaged_task和shared_future扩展。最后分享一个小技巧如果你在排查某个异步任务为什么卡住先把代码里所有std::async调用列出来检查它们的返回值是否都被接住了、future是否都被消费了、有没有promise被创建后一直没set。这三个检查项能解决大多数类似伪死锁的问题。写异步代码最忌讳想当然——动手前先在脑子里把谁生产、谁消费、谁等待、谁释放四个角色过一遍比事后调试省十倍的精力。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑