⚡C++ 并发编程

C++:futurepromisepackaged_taskasync

面试回答

常见问法

  • futurepromisepackaged_taskasync 分别是什么?它们之间是什么关系?
  • future::get() 为什么通常只能调用一次?
  • std::asyncstd::thread 有什么区别?
  • std::launch::asyncstd::launch::deferred 有什么区别?
  • promise 怎么传返回值、异常?如果忘了设置结果会怎样?
  • packaged_taskasync 的使用场景怎么选?

回答

这几个工具本质上都在解决一个问题:异步任务执行完以后,结果、异常、完成状态怎么安全地回到调用方。

它们的核心关系可以概括为一条链:

  • future结果接收端
  • promise结果生产端
  • packaged_task把可调用对象包装成“执行后自动把结果写进共享状态”的任务
  • async标准库提供的高级异步启动接口,帮你把任务执行和结果返回串起来

它们背后通常都依赖一个共享状态(shared state),里面至少包含:

  • 返回值
  • 异常对象
  • 是否完成的标记

一句话区分

  • thread 解决的是:谁去执行
  • future/promise 解决的是:结果怎么回来
  • async 解决的是:既帮你执行,又帮你拿结果

典型回答

如果面试官问这四者关系,可以这样答:

futurepromisepackaged_taskasync 都围绕“异步结果传递”展开。 future 是读端,负责等待并获取结果;promise 是写端,负责手动设置结果或异常;packaged_task 是把一个函数包装成任务,调用后会自动把返回值写到关联的 futureasync 则是更高级的封装,既负责启动任务,又直接返回 future。 它们底层都有共享状态,所以不仅能传值,也能传异常和完成状态。工程里如果只是想简单启动异步任务并拿结果,优先用 async;如果要手动控制结果回传,或者线程和结果来源分离,就用 promise;如果要把任务对象和 future 绑定起来,适合任务调度场景,就用 packaged_task

追问

  • 为什么 future::get() 只能调用一次?如果多个地方都要读结果怎么办?
  • async 默认策略安全吗?为什么面试里最好主动写 std::launch::async
  • promise 析构时如果还没设置结果会发生什么?
  • async 会不会一定创建新线程?
  • packaged_task 和普通 lambda + promise 的本质区别是什么?
  • future 能不能做回调、继续链式 then?标准库原生支持强吗?

原理展开

1. 共享状态:这套机制的核心

这几类工具的共同基础是共享状态。可以把它理解成一个“结果邮箱”,异步任务执行完后,把内容投递进去;调用方拿着 future 去等这个邮箱。

共享状态一般包含:

  • 结果值
  • 异常
  • 是否 ready
  • 等待/同步机制

所以 future 不只是“拿返回值”,它其实是:

  • 一个等待句柄
  • 一个结果读取器
  • 一个异常重抛入口
std::future<int> fut = std::async(std::launch::async, [] {
    return 42;
});

std::cout << fut.get() << '\n'; // 阻塞直到结果就绪

这里 get() 做了两件事:

  1. 等待任务完成
  2. 取出结果;如果任务里抛异常,会在这里重新抛出

2. future:读结果,不负责执行

std::future<T> 本身不是线程,也不启动任务,它只是一个“将来能拿到 T 的对象”。

关键特性

  • get() 会阻塞直到结果就绪
  • wait() / wait_for() / wait_until() 只等待,不取值
  • get() 通常只能调用一次
  • future 一般是独占读取,是 move-only,不可拷贝

为什么 get() 通常只能调用一次?

因为对 std::future 来说,get() 会把共享状态中的结果“取走”,调用后该 future 通常失效,不能再继续读。

std::future<int> f = std::async(std::launch::async, [] { return 10; });

int x = f.get();   // OK
// int y = f.get(); // 错误:future 已经没有可取结果了

如果多个消费者都要读结果怎么办?

std::shared_future

std::future<int> f = std::async(std::launch::async, [] { return 99; });
std::shared_future<int> sf = f.share();

std::cout << sf.get() << '\n';
std::cout << sf.get() << '\n'; // 可重复读

面试要点

  • future结果句柄,不是执行实体
  • get() 不仅取值,也负责异常传播
  • 需要多次读取时用 shared_future

3. promise:手动写结果的生产端

std::promise<T> 用来主动把结果或异常写入共享状态,再由对应的 future 读取。

最典型场景

  • 工作线程和结果返回逻辑分开
  • 任务不是一个简单函数返回,而是由外部事件驱动完成
  • 你需要手动决定“什么时候算完成、返回什么、抛什么异常”
std::promise<int> p;
std::future<int> f = p.get_future();

std::thread t([p = std::move(p)]() mutable {
    p.set_value(100);
});

std::cout << f.get() << '\n';
t.join();

传异常

promise 不仅能 set_value,还能 set_exception

std::promise<int> p;
std::future<int> f = p.get_future();

std::thread t([p = std::move(p)]() mutable {
    try {
        throw std::runtime_error("something wrong");
    } catch (...) {
        p.set_exception(std::current_exception());
    }
});

try {
    std::cout << f.get() << '\n';
} catch (const std::exception& e) {
    std::cout << e.what() << '\n';
}
t.join();

常见追问:如果 promise 提前析构且没写结果呢?

这会导致关联的 futureget() 时收到 std::future_error,错误原因通常是 broken promise

这点特别能体现你对边界情况的理解:

promise 必须最终把共享状态置为 ready,要么设置值,要么设置异常;如果生产端提前消失,消费者拿结果时会发现“承诺被打破”。

面试要点

  • promise手动通信模型
  • 它不负责启动线程
  • 适合线程/任务执行和结果回传分离的场景
  • 别忘了异常路径也要 set_exception

4. packaged_task:把函数包装成“自动写结果”的任务

std::packaged_task<R(Args...)> 可以把一个可调用对象包装起来。 当你执行这个任务时,它会自动把返回值或异常写入关联的共享状态,然后由 future 去取。

理解重点

它相当于把:

  • “执行函数”
  • “把结果写进 future 对应共享状态”

这两件事绑定在一起。

std::packaged_task<int()> task([] {
    return 123;
});

std::future<int> f = task.get_future();

std::thread t(std::move(task));
std::cout << f.get() << '\n';
t.join();

它和 promise 的区别

  • promise:你自己手动 set_value
  • packaged_task:函数执行完,结果自动写入

所以 packaged_task 常见于:

  • 自己写线程池/任务队列
  • 任务先入队,稍后由某个工作线程执行
  • 既想保留“任务对象”的概念,又想拿 future

面试要点

如果面试官问 “那为什么不用 promise 就好了?”

可以这样答:

promise 适合手动控制结果;packaged_task 更适合“一个函数就是一个任务”的模型,因为它把执行和结果写回绑定好了,特别适合任务调度框架。


5. async:最高层封装,但别忽略启动策略

std::async 是标准库提供的高级接口。你传一个可调用对象进去,它返回 future,底层帮你建立共享状态,并决定任务何时执行。

std::future<int> fut = std::async(std::launch::async, [] {
    return 42;
});

std::cout << fut.get() << '\n';

为什么说它是高级封装?

因为它相当于帮你做了几件事:

  • 创建共享状态
  • 包装任务
  • 启动或延迟执行
  • 返回 future

所以它是“最省事”的方式。

重点:启动策略(launch policy)

这是面试高频点。

std::launch::async

表示任务应以异步方式执行,通常意味着尽快在另一个执行上下文里运行。

auto f = std::async(std::launch::async, [] {
    return work();
});
std::launch::deferred

表示任务延迟执行,直到你调用 get()wait() 时才真正运行,而且通常由调用 get()/wait() 的线程执行。

auto f = std::async(std::launch::deferred, [] {
    return work();
});

两者的核心区别

  • async:更像“现在就派出去做”
  • deferred:更像“先记账,等你真要结果时再做”

默认策略要不要说?

一定要说。默认调用:

auto f = std::async([] { return 1; });

其策略通常等价于允许实现自己决定使用 asyncdeferred,或者两者之一。 也就是说:你不显式写策略,就把行为的一部分交给了实现。

面试中建议主动说:

我在工程里通常会显式指定启动策略,避免默认策略带来时机不确定、性能行为不透明的问题。

常被追问:async 会不会一定创建新线程?

不保证。 标准保证的是异步语义或延迟语义,不保证一定是“新建一个 OS 线程”,更不等于线程池。

这是很容易答错的点。高分回答应该说:

std::async 关注的是任务启动语义,不是线程创建细节。它不是线程池接口,是否创建新线程由实现和策略共同决定。


6. 异常传播:这套机制的一个核心价值

这是面试里很加分的点。 因为线程函数如果直接抛异常,不按设计处理,问题会很严重;而 future/promise/async 这套模型,能把异常也当成结果的一部分传回来。

async 的异常传播

auto f = std::async(std::launch::async, []() -> int {
    throw std::runtime_error("failed");
});

try {
    int x = f.get();
} catch (const std::exception& e) {
    std::cout << e.what() << '\n';
}

promise 的异常传播

std::promise<int> p;
auto f = p.get_future();

try {
    throw std::runtime_error("error");
} catch (...) {
    p.set_exception(std::current_exception());
}

面试要点

  • 异常不是在线程里直接“自动回到主线程”
  • 它是通过共享状态保存,再在 future::get()重新抛出
  • 所以 get() 不只是“取值”,它也是“错误边界”

7. 工程实践:怎么选

场景 1:只想简单起个异步任务并拿结果

优先用 std::async

优点:

  • 简洁
  • 自带 future
  • 自动处理异常传播
auto f = std::async(std::launch::async, [] {
    return compute();
});

场景 2:任务执行和结果回传不是一个函数完成

promise + future

例如:

  • 网络回调到了才算完成
  • 某个线程等待外部事件再写结果
  • 想自己控制成功/失败时机

场景 3:要把任务对象塞进队列,后面再由 worker 执行

packaged_task + future

这类场景最常见于:

  • 自定义线程池
  • 任务调度器
  • 生产者消费者模型

场景 4:多个地方都要读同一个结果

shared_future


8. 面试里值得主动补充的边界细节

边界 1:future 不是可复制的

std::future 通常是 move-only,因为它表达的是独占接收权

边界 2:promise::get_future() 一般只能调用一次

一个 promise 只对应一个结果通道,不能反复生成多个独占 future

边界 3:packaged_task 也是单次结果模型

执行一次任务,对应一个共享状态;要复用通常需要重新准备任务或 reset() 后再建立下一轮使用。

边界 4:async 返回的 future 可能影响析构行为

这是高频追问点。 对于 std::async(std::launch::async, ...) 创建的任务,如果返回的 future 是最后一个引用,某些情况下它的析构可能会等待异步任务完成。 所以工程里不要随手丢弃 future,否则容易出现你没意识到的阻塞。

可以这么答:

async 的 future 不只是一个结果对象,它还可能影响任务的同步时机,所以我一般不会忽略返回值,而是明确 get()wait(),或者说明我就是不关心结果。

边界 5:标准 future 能力其实不算强

原生 std::future 缺少很多现代异步框架常见能力,比如:

  • 原生 continuation/then 链式操作弱
  • 取消能力弱
  • 组合多个 future 的能力有限

这不是必须答,但如果面试偏工程或中高级,很加分。


对比总结

future / promise / packaged_task / async 对比

概念本质角色谁负责执行任务谁负责写结果谁负责读结果典型场景优点缺点
future结果接收端不负责执行不负责写自己 get()等待异步结果、传异常接口统一,支持等待与异常传播一般只能读一次,功能偏基础
promise结果生产端不负责执行手动 set_value/set_exception关联的 future手动控制完成时机、事件驱动回传结果灵活,边界清晰容易遗漏异常路径或 broken promise
packaged_task任务包装器调用它的人/线程执行完成后自动写关联的 future任务队列、线程池、自定义调度把“执行”和“回传结果”绑定,适合任务化模型接口比 async 更底层,管理更繁琐
async高层异步启动接口标准库根据策略处理内部自动写返回的 future简单异步任务代码最短,异常传播天然支持策略不显式时行为不透明,不是线程池接口

std::threadstd::async 对比

维度std::threadstd::async
关注点启动执行体启动任务并返回结果句柄
返回值处理需要手动传回直接拿 future
异常传播需自己处理自动通过 future::get() 传播
资源管理join/detach由 future/shared state 参与管理
适用场景强控制线程生命周期更关注“异步算完把结果给我”
常见误区只开线程不管结果误以为一定建新线程或等于线程池

futureshared_future 对比

维度futureshared_future
读取次数一般一次可多次读取
所有权独占共享
是否可拷贝不可拷贝,通常 move-only可拷贝
场景单消费者多消费者

std::launch::asyncstd::launch::deferred 对比

策略何时执行由谁执行特点适用场景
async尽快异步执行通常在独立执行上下文真正并发的可能性更高明确需要异步并行
deferredget()/wait() 时才执行调用等待函数的线程惰性执行,不一定并发可能不需要执行、或希望延后成本

易错点

  • future 当成线程本身。 future 只是结果句柄,不代表线程。
  • 认为 promise 会自动启动任务。 不会,它只负责写结果。
  • 不知道 future::get() 会“消费结果”。 想多次读取要用 shared_future
  • std::async 却不提启动策略。 这是面试里经常被继续追问的点。
  • 以为 std::async 一定创建新线程。 标准不保证,也不等于线程池。
  • 忽略异常传播。 future 不仅能传值,也能把异常重新抛给调用者。
  • 忘了处理 promise 未写结果就析构的情况。 这会导致 broken promise。
  • 线程示例里随手按引用捕获 promise,却没考虑生命周期。 更稳妥的是把 promise move 到线程里。
  • 丢弃 async 的返回 future。 可能隐藏同步/阻塞语义,工程里不建议。

记忆技巧

  • 一条通信链:

    1. promise 写结果
    2. future 读结果
    3. packaged_task 执行后自动写
    4. async 帮你把执行和回传都组起来
  • 一句高频记忆句:

    thread 解决“谁去做”,future/promise 解决“结果怎么回来”。

  • 场景记忆法:

    • 手动回传promise
    • 任务对象化packaged_task
    • 最省事直接用async
    • 多处读结果shared_future
  • 判断题口诀:

    要不要自己控制完成时机?要,就想 promise。 要不要把函数塞进任务队列?要,就想 packaged_task。 只是想异步算完拿结果?优先 async


面试速答版

futurepromisepackaged_taskasync 都是为了解决异步结果传递问题。future 是读端,用来等待并获取结果;promise 是写端,手动设置返回值或异常;packaged_task 是把一个可调用对象包装成任务,执行后会自动把结果写进关联的共享状态;async 是更高层的封装,负责启动任务并直接返回 future。它们底层都有共享状态,所以不仅能传值,也能传异常。工程里简单异步任务优先用 async,要手动控制结果回传就用 promise,做任务队列或线程池更适合 packaged_task。另外 future::get() 一般只能调用一次,如果多个地方要读同一个结果,就用 shared_future


面试加分版

这套工具我一般分成两层理解:执行层结果层threadasync 更偏执行层,决定任务怎么跑;future/promise 更偏结果层,决定结果、异常、完成状态怎么传回来。

先说关系:future 是结果接收端,promise 是结果发送端,它们通过一个共享状态关联起来。共享状态里不只是返回值,还包含异常和 ready 标记,所以 future::get() 既能拿值,也能把异步任务里的异常重新抛到调用方。promise 适合手动控制完成时机,比如外部事件到了、某个 worker 真正结束了,再 set_valueset_exception

packaged_task 可以理解为“带 future 出口的任务包装器”。你给它一个函数,任务执行完成后会自动把返回值写入共享状态,所以它特别适合任务队列、线程池这种场景,因为你既能把任务对象塞进队列,又能把 future 返回给调用方。

async 则是最高层封装,既帮你创建共享状态,也帮你启动任务并返回 future,所以写法最简单。但面试里我一定会强调 launch policy:std::launch::async 表示异步执行,std::launch::deferred 表示延迟到 get()/wait() 时才执行。默认策略可能让实现自己决定,所以工程里如果我关心时机和并发行为,通常会显式写 std::launch::async

最后说选择原则:如果只是“异步执行一下然后拿结果”,优先 async;如果执行和完成时机是分离的,比如事件驱动或手动通知,就用 promise;如果要做任务调度、线程池、任务队列,就更适合 packaged_task。另外要注意 future::get() 一般只能调用一次,多个消费者要共享结果时应该改用 shared_future