资讯动态

std::shared_future 详解:让异步结果在多线程中安全共享

发布时间:2026/10/21 17:59:24 来源:尧图企业网站定制
如果把std::future比作一张只能使用一次的火车票那么std::shared_future就是一张可以无限次复印给多人使用的电子通行证——最终结果依旧是同一个但每一份复印件都能随时去查询。在 C 并发编程里std::shared_future是异步结果共享的核心工具它允许一个异步任务只执行一次却能把最终结果或异常安全地分享给任意多个线程去等待和读取。无论你是在做线程池、任务编排、配置预加载还是想用std::promise实现一次性事件广播shared_future都能帮你省下不少条件变量的样板代码。这篇文章我会从它解决什么问题开始讲接着拆解背后的共享状态机制再给出可以直接抄的实战示例和排错清单。适合正在学习 C 并发或者想在真实项目里把异步结果用得更加顺手的开发者。1. 为什么需要 shared_future从 std::future 的局限说起1.1 普通 std::future 的“一次性”限制std::futureT是 C11 引入的异步结果句柄。通过std::async启动任务后我们拿到的future可以阻塞等待结果并最终通过get()取出值。很多初学者在这里会踩到第一个坑future::get()只能调用一次。第一次调用时它会从共享状态中获取结果第二次调用时标准规定它“可能抛出异常或导致未定义行为”这取决于实现。事实上future::get()返回的是T它会把结果移动出来所以从语义上就不允许第二次读取。这个限制带来的直接问题就是在多线程场景下如果两个线程都想等待同一个异步任务的结果你没办法安全地共享同一个future对象。常见的做法是外面加一把锁、一个条件变量再用一个共享指针存结果或者干脆每个线程自己重新发起一次计算——前者代码冗长后者浪费算力。1.2 shared_future 如何打破“一对一”的僵局std::shared_futureT在标准库里的定位就是解决“一次计算、多人读取”的问题。它是std::future的变体允许拷贝构造也允许拷贝赋值。你对同一个异步结果可以持有多个shared_future句柄每个句柄都可以独立调用get()和wait()互不干扰。最典型的转换方式是调用future::share()#include future #include iostream #include thread int compute() { std::this_thread::sleep_for(std::chrono::seconds(1)); return 42; } int main() { // 注意share() 之后原来的 future 就失效了 std::shared_futureint sf std::async(std::launch::async, compute).share(); std::thread t1([sf]() { std::cout t1: sf.get() std::endl; }); std::thread t2([sf]() { std::cout t2: sf.get() std::endl; }); t1.join(); t2.join(); }这里有个容易忽略的细节share()不仅仅是在原来的future上包了一层它会把底层的共享状态所有权转移给新的shared_future并且让原来的future变得无效。因此share()之后不要再用原来那个future调用get()否则就是访问无效状态。为什么shared_future能允许多次读取因为它的get()返回的是const T是“读”而不是“搬”。结果一直存放在共享状态里直到最后一个句柄销毁这样每个线程读到的都是同一个结果而且不会破坏原始数据。2. shared_future 核心机制剖析共享状态与线程安全2.1 共享状态到底是个什么东西理解shared_future之前必须先理解 C 并发库里反复出现的“共享状态”shared state。它是一个抽象概念可以理解为异步结果存放的“盒子”里面可能有三种情况任务尚未执行完结果是“未就绪”状态任务执行完毕盒子里有结果值或者异常任务因为promise被销毁而没有产出结果盒子里的等待者会收到broken_promise异常。std::future和std::shared_future本质上都是指向这个盒子的“钥匙”。future是唯一钥匙而shared_future是无限可复制的钥匙。不管你有多少把钥匙盒子只能打开一次取结果但读取的过程中盒子不会被销毁。举个例子当你写std::promiseint prom; std::shared_futureint sf prom.get_future().share();执行prom.get_future()时系统创建了一个共享状态并返回一个future指向它。紧接着share()把状态所有权转成shared_future原来的future失效。之后你可以随意sf1 sf; sf2 sf;这三个shared_future仍然指向同一个共享状态。当最后一个句柄和promise都销毁后共享状态才被释放。2.2 并发访问同一个 shared_future 是否安全这是很多人在使用时的直觉盲区。先说结论多个线程并发调用同一个shared_future对象上的wait()、get()、wait_for()、wait_until()这些成员函数是安全的不会造成数据竞争。标准委员会在shared_future的接口设计里专门做过约束保证这些只读操作可以并发执行。但要注意这个安全保证有一个隐藏前提你只是“读取”结果引用。如果你拿到const T之后自己再去修改它指向的对象那就是另外一回事了。如果T是个普通容器或可变对象多个线程同时读同一个const T本身没问题但如果你在某个线程里通过const_cast或别的方式去写它就会产生数据竞争。所以shared_future的安全边界是“共享状态句柄本身”而不是“结果对象内容的写保护”。一个稳妥的实践是如果下游线程只需要只读访问直接在线程里拷贝shared_future然后各读各的。如果下游线程要写结果就不要用shared_future共享裸对象而是考虑共享std::shared_ptrT并配合锁或者让每个线程拷贝一份结果再处理。2.3 get() 的返回类型const 引用的设计逻辑std::shared_futureT::get()的签名和普通future差别很大。这里列一个对比表方便快速查阅类型std::future ::get()std::shared_future ::get()T 是非引用类型返回T返回const TT 是T返回T返回TT 是void返回void返回void普通future返回右值引用因为它允许你“移动”出结果并且只能移动一次。而shared_future要支持多次读取如果它也返回T第一次get()就会把结果搬走第二次get()拿到的就是一个被移空的“残骸”。所以它必须返回const T把结果留在共享状态里。这个设计带来的实际影响是如果你的结果类型是std::unique_ptr这类只能移动、不能拷贝的类型那shared_future的get()只能让你拿到一个const std::unique_ptrT你既不能把它搬出来也不能复制出一份新的智能指针。此时你应该考虑改用std::shared_ptrT作为异步结果的类型让每个线程都能安全地持有自己的一份额外读权限。3. 实战三个高频使用场景与完整代码3.1 场景一多线程等待同一个初始化结果假设你有一个程序启动时要从配置文件或网络加载一批数据加载完成后后面几十个工作线程都需要读取这份数据来干活。如果你让每个线程各加载一次不仅浪费资源还可能因为数据源不一致造成逻辑混乱。这时候用一个shared_future是最直接的方案#include future #include iostream #include vector #include thread #include chrono // 模拟耗时初始化 std::vectorint load_config() { std::this_thread::sleep_for(std::chrono::milliseconds(800)); return {1, 2, 3, 4, 5}; } int main() { std::shared_futurestd::vectorint cfg_future std::async(std::launch::async, load_config).share(); std::vectorstd::thread workers; for (int i 0; i 4; i) { workers.emplace_back([cfg_future, i]() { // 每个线程阻塞等待配置加载完成 const std::vectorint cfg cfg_future.get(); std::cout worker i started, config size cfg.size() std::endl; }); } for (auto w : workers) { w.join(); } return 0; }我在这个代码里特意用了std::launch::async而不是让系统默认选择策略。原因稍后会在“状态流转”里详细说这里先记住结论当多个线程依赖同一个shared_future时尽量显式指定异步策略避免任务被延迟到线程调用get()时才同步执行。3.2 场景二用 promise shared_future 实现一次性事件广播有时并不需要真的返回一个计算值你只是想用一个“事件”通知一批线程可以开始干活了。传统写法是mutex condition_variable bool flag代码量不少。用std::promisevoid配合shared_future可以把器表达得干净利落#include future #include iostream #include thread #include vector #include chrono int main() { std::promisevoid ready_promise; std::shared_futurevoid ready ready_promise.get_future().share(); std::vectorstd::thread workers; for (int i 0; i 4; i) { workers.emplace_back([ready, i]() { ready.wait(); // 阻塞等待信号 std::cout thread i starts working std::endl; }); } std::this_thread::sleep_for(std::chrono::milliseconds(200)); ready_promise.set_value(); // 广播解除阻塞 for (auto w : workers) { w.join(); } return 0; }这里std::promisevoid只负责标记“完成”不携带任何数据。多个shared_futurevoid句柄被拷贝到各个线程里每个线程都在wait()上挂起。当主线程调用set_value()之后所有等待在同一个共享状态上的线程会被同时唤醒。这种“一次性栅栏”比手写条件变量更简洁而且不太容易遗漏唤醒时机的处理。实际项目里这种模式很适合做“健康检查通过后再放开流量”“所有依赖模块就绪后再并行启动任务”之类的控制面。3.3 场景三异常安全与超时控制异步任务不总是成功shared_future同样会把异常传播给所有等待者。如果任务内部抛出了异常并且被promise::set_exception保存下来那么每一个调用get()的线程都会重新抛出同一个异常。举个完整例子#include future #include iostream #include stdexcept #include thread void producer(std::promiseint prom) { try { throw std::runtime_error(compute failed); } catch (...) { prom.set_exception(std::current_exception()); } } int main() { std::promiseint prom; std::shared_futureint sf prom.get_future().share(); std::thread t(producer, std::ref(prom)); t.detach(); std::thread t1([sf]() { try { int v sf.get(); std::cout t1 got v std::endl; } catch (const std::exception e) { std::cout t1 caught: e.what() std::endl; } }); std::thread t2([sf]() { try { int v sf.get(); std::cout t2 got v std::endl; } catch (const std::exception e) { std::cout t2 caught: e.what() std::endl; } }); t1.join(); t2.join(); return 0; }多个线程都会看到同一个std::runtime_error。如果你在一个线程里 catch 了不会影响另一个线程继续捕获异常因为异常对象存放在共享状态里每次get()时都会重新抛出一次。超时控制也是常用技巧。如果你不想无限期阻塞可以用wait_for或wait_untilstd::shared_futureint sf /* ... */; if (sf.wait_for(std::chrono::milliseconds(100)) std::future_status::ready) { std::cout result: sf.get() std::endl; } else { std::cout not ready, do something else std::endl; }注意wait_for之后如果状态已经 ready随后的get()是会立即返回的不会再次阻塞。这在轮询异步任务时非常实用。4. 状态流转与实现陷阱从创建到读取的全过程4.1 状态流转从 promise/async 到 readyshared_future背后的共享状态从创建到销毁大致经历这几个阶段创建通过std::promise的get_future()、std::packaged_task的get_future()或者std::async的返回值产生指向共享状态的future。转移调用future::share()将状态所有权转为shared_future。就绪任务执行完成或者promise::set_value()/set_exception()被调用状态变为 ready。此后所有等待者被唤醒。读取各线程通过shared_future::get()读取结果或重新抛出异常。销毁最后一个指向共享状态的shared_future被销毁共享状态随之释放。这里有一个常见的误用如果你使用std::async的默认启动策略任务可能是延迟执行std::launch::deferred的。也就是说std::async返回之后任务不会立刻开始执行要等到某个future或shared_future调用wait()/get()时才会同步执行。如果多个线程同时挂起在同一个deferred状态的shared_future上那么第一个调用wait()的线程会去执行任务其他线程仍然等待。这不算错误但会造成不可预测的延迟。因此在“多线程等待同一个结果”的场景下我建议显式指定std::launch::async或者干脆用std::promise 手动线程来明确控制执行时机。4.2 陷阱一broken_promise 异常broken_promise是一个容易被忽略的坑。当std::promise对象在尚未调用set_value()/set_exception()的情况下被销毁而指向其共享状态的shared_future仍然存在时等待者调用get()会抛出std::future_error错误码为std::future_errc::broken_promise。例如下面这段代码std::shared_futureint sf; { std::promiseint prom; sf prom.get_future().share(); // prom 在这里被销毁但没有 set_value } try { int v sf.get(); // 永远不会 return } catch (const std::future_error e) { std::cout error: e.what() std::endl; }这通常是因为你忘了在某个分支里设置值或者 promise 所在的线程异常退出了。排查技巧是在所有promise可能提前销毁的路径上要么调用set_value要么调用set_exception(std::make_exception_ptr(...))来兜底。如果实在无法预知结果也可以尝试捕获future_error并判断e.code()是否为future_errc::broken_promise。4.3 陷阱二get() 返回引用背后的生命周期shared_futureT::get()返回const T这个引用指向共享状态内部的对象。它不会因为某个shared_future句柄被销毁而失效——引用目标的生命周期由共享状态管理而共享状态会一直活到最后一个句柄被销毁。所以几个线程各自拿着自己的副本先调用get()的线程得到引用其他线程后调用也得到同样的引用这是安全的。真正危险的是如果共享状态中存储的结果对象本身是std::reference_wrapper之类包装的引用或者你通过std::promiseT存储了一个外部对象的引用那么结果引用的生命周期就取决于外部对象。此时共享状态销毁时不会去“析构”引用只会丢掉引用本身。这种情况需要格外注意异步任务必须确保它设置引用的对象在最后一个等待者读取完成之前仍然存活否则就是经典的悬垂引用问题。4.4 性能开销与需要注意的拷贝行为shared_future的拷贝很轻量内部只是在共享状态的引用计数上加一不会拷贝结果数据。所以往线程里传递shared_future时不用担心拷贝大对象。get()返回const T本身也避免了结果拷贝真正耗时的复制发生在“把它赋值给本地变量”时比如说const auto cfg cfg_future.get(); // 这会拷贝一份 vector如果你只想要读取应该使用引用绑定const auto cfg cfg_future.get(); // 只获得引用不拷贝这个差异在结果很大时非常明显。我在一个数据加载模块里实测过一个 50MB 的配置对象若不注意引用绑定线程每次get()都会复制一份4 个线程就多出 200MB 内存占用。而引用绑定只会增加少量共享引用计数几乎可以忽略不计。5. 常见问题排查与开发建议5.1 编译期错误future 不可拷贝这是把std::future用作函数参数、放进标准容器或者向多个线程传递时最经典的问题。编译器通常会报类似“future is not copy constructible”的错误。解决方案很明确在传递之前调一次share()把类型改为std::shared_futureT。例如void worker(std::shared_futureint sf); // 参数直接拷贝 std::futureint f std::async(...); worker(f); // 编译错误 worker(f.share()); // OK如果你需要把一个shared_future存入std::vector直接拷贝即可不需要移动语义。5.2 运行期问题get() 调用后程序报错或卡住常见原因有三类第一类是在普通future上多次调用get()。如果你看到程序在第二次调用时崩溃或输出怪异值先检查是否不小心把std::future当成shared_future使用。统一改用share()可以彻底规避。第二类是等待者永远等不到结果。多半是某个promise没有被set_value或者任务线程因为异常退出了没有设置异常。排查时可以先用wait_for(短超时)判断是否 ready再用调试器检查线程栈更粗暴但有效的方法是直接捕获future_error并看报错码。第三类是deferred策略导致的“看似卡死”。使用std::async默认策略时如果系统选择std::launch::deferred且你调用了wait_for来判断状态返回结果永远是std::future_status::deferred永远不会是ready。这会误导很多人。遇到这种情况要么显式指定std::launch::async要么在判断时把deferred单独处理auto status sf.wait_for(std::chrono::milliseconds(0)); if (status std::future_status::deferred) { // 需要手动触发执行 sf.wait(); } else if (status std::future_status::ready) { // 已经有结果 }5.3 异常排查速查表现象可能原因解决/检查方向future_error: broken_promisepromise 被销毁且未设置结果确保所有分支都 set_value/set_exception多个线程同时 get 异常混乱把 std::future 传给了多个线程改用 std::shared_future第二次 get 崩溃future 只能 get 一次调用 share() 或换 shared_futurewait_for 返回 deferredasync 默认策略选了延迟执行显式指定 std::launch::async程序内存占用过高get() 返回值被多次拷贝使用 const 引用接收结果结果对象修改冲突多线程通过 const 引用修改对象改用锁或每个线程独立拷贝5.4 排错工具与心智模型在排查异步代码时我习惯先在脑里画一个“谁指向共享状态”的图有几个shared_future句柄、一个promise、一个packaged_task或一个async任务在指向同一块状态。只要确认“设置端”是否恰好被调用一次“读取端”是否都指向同一个状态问题基本就能定位。如果状态迟迟不 ready就去查设置端如果状态已经被销毁就去查句柄生命周期如果出现数据竞争就去查结果对象本身的读写。现代调试器可以直接看到std::promise和std::shared_future的内部字段但可读性不强。更快的办法是在关键调用点打日志set_value前打一条get前打一条看看哪一步没发生。6. 进阶活用shared_future 与 C20 并发原语的对比和选型6.1 与其他并发原语的核心差异很多 C 开发者会问我已经有mutex condition_variable了为什么还要用shared_future这其实是个“需求复杂度”的问题。用条件变量做一次性事件广播你需要维护一个bool标志位要处理notify_one与notify_all的选择还要小心虚假唤醒和锁泄漏。而shared_future把状态机封装好了你只需要关心“设置结果”和“读取结果”两件事。我把几种常见并发原语的适用场景整理成表格工具核心语义适合场景不适合场景std::future一对一一次性取结果单线程等待任务结果多线程共享结果std::shared_future一对多多次读结果一次计算、多人等待/读取需要重复产生新结果的场景mutex condition_variable手动管理状态和唤醒复杂状态机、多条件切换简单一次性事件代码量偏多std::latch一次性倒数闸门N 个线程等待某个计数归零需要携带结果数据的场景std::barrier可复用屏障多个线程分阶段同步需要异步结果传播std::counting_semaphore资源信号量控制并发访问数量共享异步结果6.2 选型建议什么时候必须用 shared_future我的判断标准很简单如果你要等的不只是“事件发生”还要“读取事件产生的结果”并且这个结果要被多个消费者使用那么shared_future是最自然的表达。它同时具备三个优点结果只计算一次所有等待者自动被唤醒异常能透明传递。如果只是想要一个纯粹的“开始信号”不需要携带数据像我前面例子里的promisevoid也是好选择但如果团队里已经统一使用了std::latch那么用它来做一次性栅栏更现代。反过来说如果你需要的是“每隔一段时间生成一个新结果多个消费者持续获取最近一次结果”shared_future并不合适那应该考虑mutex 轮询或者用std::atomic加版本号来设计。C20 的std::jthread、std::stop_token可以和shared_future配合使用后台线程用jthread管理生命周期计算结果通过promise暴露给外部多个观察者。C23 的std::async还没有完全脱离传统模型但在协程场景里你也可以用shared_future配合co_await或者第三方库的适配器把异步结果的广播能力延伸到协程世界。在我个人的实践中shared_future最容易被低估的一点是它能够把“异步任务的生命周期管理”和“结果读取”彻底解耦。只要共享状态还在哪怕任务线程早就结束了甚至promise也已经销毁只要提前把值设进去消费者随时都能拿到结果。这个特性让它在预加载、缓存重建、配置热更新这类场景里格外好用。如果你现在还在用一堆手写条件变量做一次性状态通知不如试着换成shared_future代码量会减少一大截线程安全问题也会少很多。遇到复杂情况再考虑更重的同步原语这才是务实的选择。

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

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

免费获取报价 →
↑