跳转至

异步任务与期物:async, future 与 promise (Asynchronous Programming)

在掌握了 std::thread、互斥锁与条件变量之后,很多初学者在编写实际业务时常常感到繁琐痛苦:

“我只是想在后台开一个子线程去计算斐波那契数列,或者异步读取一个大文件,拿到返回值而已。难道为了拿回一个简单的 int,我非要声明一个全局共享变量、定义一把互斥锁、还要再写一个条件变量去通知主线程吗?” “如果子线程在运行过程中抛出了异常,主线程怎么能捕获到这个异常?”

C++11 在标准库 <future> 中,为我们提供了一套更现代、更高级的基于任务(Task-Based)的异步编程抽象。它把复杂的线程创建、锁管理和生命周期调度封装在幕后,让开发者只需关注“提交任务”与“获取结果”。

本文将为你通俗拆解这套由 std::future、std::promise、std::packaged_task 与 std::async 构成的异步编程体系。


1. 概念模型:提货单与单向通道

要理解异步编程,最形象的类比就是网购或者在餐厅点餐:

sequenceDiagram
    autonumber
    actor Customer as 主线程 (顾客)
    participant Kitchen as 后台子线程 (后厨)

    Customer->>Kitchen: 1. 点餐: 发起耗时计算任务
    Kitchen-->>Customer: 2. 立即返回一张【提货单】 (std::future)
    Note over Customer: 顾客拿着提货单,可以自由去刷手机、干别的事
    Note over Kitchen: 后厨在后台热火朝天地做菜 (执行耗时任务)
    Customer->>Customer: 3. 准备吃饭,来到前台出示提货单: fut.get()
    Note over Customer: 如果菜还没做好,顾客在柜台短暂等待 (阻塞)
    Kitchen-->>Customer: 4. 出餐完毕,取走香喷喷的菜品 (拿回真实计算结果或捕获异常)
  • std::future(期物 / 提货凭证):代表一个“在未来某个时刻才能拿到的结果”。它是数据的读取端;
  • std::promise(承诺 / 交付端):代表履约的责任方。它是数据的写入端。

两者就像一个跨线程的单向通信管道(Channel),无论子线程是正常算出了结果,还是中途遭遇崩溃抛出了异常,都能通过这个管道安全无损地传递给主线程!


2. 深入理解 std::promise 与 std::future

std::promise 和 std::future 通常成对出现:

#include <iostream>
#include <thread>
#include <future>
#include <stdexcept>

// 子线程工作函数: 接收一个 promise 的写端
void async_worker(std::promise<int> prom, int x) {
    try {
        if (x < 0) {
            throw std::invalid_argument("参数不能为负数!");
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(500));
        prom.set_value(x * x); // 成功履约: 写入结果
    } catch (...) {
        // 捕获异常,并原封不动地通过 promise 传递出去!
        prom.set_exception(std::current_exception());
    }
}

int main() {
    // 1. 创建 promise 对象 (写入端)
    std::promise<int> prom;

    // 2. 从 promise 中获取绑定的 future 凭证 (读取端)
    std::future<int> fut = prom.get_future();

    // 3. 将 promise 移动语义 (std::move) 传入子线程 (promise 不可拷贝)
    std::thread t(async_worker, std::move(prom), -5);

    std::cout << "主线程正在并行做其他事情...\n";

    try {
        // 4. 调用 get() 阻塞等待并取回结果
        // 如果子线程抛出了异常,get() 会在主线程重新抛出该异常!
        int result = fut.get();
        std::cout << "异步计算结果: " << result << "\n";
    } catch (const std::exception& e) {
        std::cout << "主线程成功捕获到子线程的异常: " << e.what() << "\n";
    }

    t.join();
    return 0;
}

核心特性警示:

  1. 只能读取一次:fut.get() 拥有移动语义,一旦调用,该 future 的状态就会失效,再次调用会抛出 std::future_error 异常;
  2. 多线程广播(std::shared_future):如果需要 5 个工作线程同时等待同一个异步结果就绪,应该调用 fut.share() 将其转化为 std::shared_future,它允许多个线程并发多次调用 get()。

3. 任务打包器:std::packaged_task

在设计线程池或任务调度器时,我们往往需要将“一个函数及其参数”打包暂存起来,扔进队列里,供后续的工作线程拾取执行。std::packaged_task 就是为此而生的神器!

flowchart LR
    TaskFunc["可调用函数<br>int add(int a, int b)"] --> PackagedTask["std::packaged_task&lt;int(int, int)&gt;<br>自动将函数与一个内部的 promise 绑定打包"]
    PackagedTask --> GetFut["task.get_future() -> 拿到提货凭证 std::future"]
    PackagedTask --> Execute["在任意工作线程中像普通函数一样调用: task(10, 20)"]
    Execute --> ValueSet["底层自动执行 promise.set_value()"]
#include <iostream>
#include <future>
#include <thread>

int compute_heavy(int a, int b) {
    return a + b;
}

int main() {
    // 将普通函数包装为 packaged_task
    std::packaged_task<int(int, int)> task(compute_heavy);

    // 提前取走关联的 future
    std::future<int> fut = task.get_future();

    // 将 task 移交给子线程执行 (注意 packaged_task 同样不可拷贝,只能 move)
    std::thread t(std::move(task), 10, 20);

    std::cout << "主线程取回结果: " << fut.get() << "\n"; // 输出: 30
    t.join();
    return 0;
}

4. 极速异步捷径:std::async 的魔力与隐藏暗礁

std::async 是 C++ 标准库提供的最高阶异步函数。它连 std::thread 的创建和 join() 都不需要你操心,直接一行代码返回 std::future:

auto fut = std::async(std::launch::async, []() {
    return 42;
});
std::cout << fut.get() << "\n"; // 输出 42

然而,很多初学者在深度使用 std::async 时,往往会踩中两大隐蔽的暗礁:

4.1 暗礁一:启动策略 (Launch Policy) 的假异步陷阱

std::async 支持三种启动策略参数:

// 策略 1: 强制在新线程中异步并行执行
std::async(std::launch::async, func);

// 策略 2: 延迟惰性求值!根本不启动新线程,直到调用 get() 或 wait() 时,才在当前调用线程中同步执行!
std::async(std::launch::deferred, func);

// 策略 3: 默认不传策略参数 (等价于 std::launch::async | std::launch::deferred)
std::async(func); // ⚠️ 警告: 隐藏行为!

初学者大坑:如果不传策略,C++ 运行时有权根据系统当前 CPU 负载自主抉择。如果当前系统负载高,运行时可能会选择 deferred(延迟同步执行)。这会导致如果你只是等待时间(wait_for),任务可能永远都没开始运行!
生产准则:只要明确需要异步执行,必须显式指定 std::launch::async。


4.2 暗礁二:临时变量析构带来的隐式阻塞

请仔细阅读下面这看似纯良的两行代码:

void send_email(int user_id) {
    // 耗时 3 秒发送邮件...
}

void process() {
    // ❌ 灾难!初学者自以为并发发了 2 封邮件,实际却是串行耗时 6 秒!
    std::async(std::launch::async, send_email, 101);
    std::async(std::launch::async, send_email, 102);
}

为什么没有并发?

因为 std::async 返回了一个临时的 std::future。根据 C++ 标准规定:由 std::async 返回的有效 std::future,在其析构函数中会强制阻塞等待后台任务完成! 在上面的代码中,第一行语句执行完毕时,临时 future 立即析构,导致主线程在此卡顿 3 秒;随后才执行第二行语句,再次卡顿 3 秒。代码完全退化成了串行同步!

正确做法:必须用变量显式保存返回的 future 对象

void process_fixed() {
    // 用变量接收,让两个任务真正并发并行跑起来!
    auto f1 = std::async(std::launch::async, send_email, 101);
    auto f2 = std::async(std::launch::async, send_email, 102);
    // f1 与 f2 在作用域末尾析构时,两者早已经在后台并行完成
}

5. C++ 异步全家族对比一览

异步工具 角色定位 适用典型场景 是否管理底层线程
std::thread 最底层的线程原语 长驻后台的守护任务、需要精细控制线程属性 手动创建与 join
std::async 最高级的开箱即用异步函数 偶尔需要后台计算某个短时任务并直接取回结果 标准库自动调度
std::packaged_task 可调用实体的异步包装器 构建高性能线程池的任务队列,打包函数与返回值 不创建线程,仅负责打包
std::promise 底层通信管道的写入端 复杂的事件通知、跨线程状态同步、非函数模式的结果传递 不创建线程,负责管道通信
std::future 异步结果凭证(只读提货单) 阻塞等待取结果、跨线程异常接收、超时轮询 配合 async/promise/task 使用