C++

C++11 异步编程四件套详解

C++11 异步编程四件套详解

底层概览:内部操作围绕着「共享状态」

这四个组件不是并列关系,而是层层包装的关系。它们都围绕一个看不见的核心——共享状态(Shared State)工作:

┌─────────────────────────────────────────────────────┐
│                  共享状态(堆上的一块内存)           │
│   包含:结果值 / 异常 / 就绪标志                     │
└──────────────┬──────────────────────┬───────────────┘
               │ 写入端(生产者)       │ 读取端(消费者)
               ▼                        ▼
          promise                future::get()
          packaged_task          future::wait()
          async                  future::valid()
  • 共享状态是堆上的一块匿名内存 (control block),存着「结果还没好 / 已经好了(值或异常)」这个状态机,内部保证线程安全。
  • promise 和 packaged_task 是写端:负责把结果塞进共享状态。
  • future 是读端:负责从共享状态取出结果,如果还没好就阻塞等待。
  • async 是最上层的一键封装:自动创建共享状态 + 自动启动线程执行 + 自动返回 future。 7b81850e020ec9a89b54576413ef0c7a

一、std::promise + std::future:最底层的「生产者-消费者」通道

本质

promise 是结果的写端,future 是结果的读端。两者在创建时通过 promise::get_future() 绑定到同一块共享状态。

适用场景

你已经有一个线程(可能是第三方库创建的、可能是你手动 std::thread 启动的),你想在这个线程里算出一个结果,然后让主线程拿到它——但你无法修改线程启动方式,只能在线程函数内部投递结果。

完整代码

#include <iostream>
#include <thread>
#include <future>
#include <chrono>

// 线程函数:接收一个 promise,计算完毕后把结果 set 进去
void compute(std::promise<int> prom) {
    std::cout << "[Worker] 开始计算,3秒后出结果...\n";
    std::this_thread::sleep_for(std::chrono::seconds(3));

    // 把结果写入共享状态(只能写一次,写第二次会抛 future_error)
    prom.set_value(42);
    std::cout << "[Worker] 结果已投递\n";
}

int main() {
    // ① 创建一个 promise(生产者端)
    std::promise<int> prom;

    // ② 从 promise 拿到对应的 future(消费者端)
    std::future<int> fut = prom.get_future();

    // ③ 启动线程,把 promise move 进去(promise 不可拷贝)
    std::thread t(compute, std::move(prom));

    // ④ 主线程在这里阻塞等待,直到 worker 调用 set_value
    std::cout << "[Main] 等待结果中...\n";
    int result = fut.get();  // 阻塞约3秒
    std::cout << "[Main] 拿到结果: " << result << '\n';

    t.join();
    return 0;
}

关键细节

  • prom.set_value(42) 调用后,共享状态的「就绪标志」翻转为 true,所有在 fut.get() 上阻塞的线程被唤醒。
  • 如果线程函数里抛异常,可以用 prom.set_exception(std::current_exception()) 把异常也塞进共享状态,fut.get() 会重新抛出这个异常。
  • promise 禁止拷贝操作,只能 move——共享状态只有一个写端。
  • set_value() / set_exception() 只能调用一次,第二次会抛 std::future_error(错误码 promise_already_satisfied)。

注意:get_future() 只能调一次

对同一个 promise(或 packaged_task)调用第二次 get_future() 会抛 std::future_error,错误码 future_already_retrieved。也就是说读端和写端是对称的一次性绑定:写端只能 set 一次,读端只能取 future 一次。要把结果给多个消费者,请用第五节的 shared_future,而不是反复 get_future()。

std::promise<int> prom;
std::future<int> f1 = prom.get_future();
// std::future<int> f2 = prom.get_future();  // 抛 future_error(future_already_retrieved)

注意:broken_promise —— 最常见的崩溃来源

如果 promise 在还没调用 set_value / set_exception 之前就被销毁(典型场景:worker 线程提前 return、抛了未捕获的异常、或任务被取消),它会在析构时把共享状态标记为 broken。此时对面 fut.get() 不会返回,而是抛出 std::future_error,错误码为 broken_promise。

// worker 里如果直接 return 而没有 set_value:
// void compute(std::promise<int> prom) { return; }  // prom 析构 -> broken_promise

// 消费端必须这样兜底:
try {
    int r = fut.get();
} catch (const std::future_error& e) {
    if (e.code() == std::future_errc::broken_promise)
        std::cerr << "对方没给结果就挂了\n";
    else
        std::cerr << "其他 future 错误: " << e.what() << '\n';
}

经验法则:worker 线程里要么 set_value、要么 set_exception,绝不能两个都不做就退出。最稳妥的写法是用 try/catch 把整个函数体包起来,异常分支统一 set_exception。


二、std::future 读端的硬约束:get / wait / valid

这几个约束是文档最容易忽略、又最容易写出 bug 的地方,单独列一节。

成员调用次数行为
get()只能调一次阻塞到就绪;把结果 move 出来;调用后 future 变为 invalid(valid() 返回 false)。第二次 get() 是未定义行为或抛异常。
wait()可多次只阻塞等待就绪,不取值、不消费,调用后 future 仍然 valid。
wait_for(d) / wait_until(t)可多次带超时等待,返回 future_status(ready / timeout / deferred),同样不消费。
valid()任意返回该 future 是否仍关联一块共享状态。被 move 走、或 get() 之后,会变成 false。
std::future<int> fut = /* ... */;

fut.wait();          // OK,可以反复调用,只等待不取值
int a = fut.get();   // 第一次 get:拿到值,fut 变为 invalid
// int b = fut.get();// fut 已 invalid,行为未定义

std::cout << fut.valid();  // get 之后输出 false

std::future<int> fut2 = std::move(fut);  // move 后 fut.valid() == false

要点:get() 是一次性消费,想多次读同一结果用 shared_future;只想等不等值用 wait();想超时轮询用 wait_for()。


三、std::packaged_task:把函数「打包」成带 future 的可调用对象

本质

packaged_task = 一个可调用对象(函数/lambda)+ 一个内置的 promise。你调用它时,它自动执行函数,把返回值塞进内置的 promise,然后你就能通过 get_future() 拿到 future。

和 promise 的区别

promisepackaged_task
谁来写入结果你手动 set_value()执行完函数后自动把返回值塞进去
适用场景结果不是函数返回值,而是你在某处算出来的你有一个函数,只想把它的返回值异步拿回来
灵活性高:可以在任意时刻、任意地点 set低:只能和绑定的函数绑定在一起

完整代码

#include <iostream>
#include <thread>
#include <future>
#include <chrono>

// 一个普通的同步函数
int heavy_compute(int x, int y) {
    std::cout << "[Worker] 开始计算 " << x << " + " << y << "...\n";
    std::this_thread::sleep_for(std::chrono::seconds(2));
    return x + y;
}

int main() {
    // ① 打包函数:模板参数写函数的签名
    std::packaged_task<int(int, int)> task(heavy_compute);

    // ② 在任务执行前,先拿到 future(get_future 同样只能调一次)
    std::future<int> fut = task.get_future();

    // ③ 把 task move 到线程里执行(task 本身是可调用对象)
    std::thread t(std::move(task), 10, 20);

    // ④ 主线程可以干别的...
    std::cout << "[Main] 任务已交给线程,主线程继续做别的事\n";

    // ⑤ 需要结果时再 get(会阻塞到线程执行完毕)
    std::cout << "[Main] 等待结果...\n";
    int result = fut.get();
    std::cout << "[Main] 结果: " << result << '\n';

    t.join();
    return 0;
}

进阶用法:延迟执行 / 线程池

packaged_task 的强大之处在于:它只是包装,不决定在哪执行。你可以:

std::packaged_task<int()> task([]{ return 42; });
std::future<int> fut = task.get_future();

// 方式1:当前线程同步执行(future 立刻就绪)
task();

// 方式2:丢给线程池
// thread_pool.enqueue(std::move(task));

// 方式3:丢给新线程
std::thread t(std::move(task));
t.detach();

四、std::async:一行代码搞定异步调用

本质

async = 自动创建 packaged_task + 自动选择线程执行 + 自动返回 future。它是最高层的封装。

完整代码

#include <iostream>
#include <future>
#include <chrono>

int heavy_compute(int x) {
    std::this_thread::sleep_for(std::chrono::seconds(2));
    return x * 2;
}

int main() {
    // 一行搞定:启动异步任务 + 返回 future
    std::future<int> fut = std::async(std::launch::async, heavy_compute, 21);

    std::cout << "[Main] 任务已启动,做别的事...\n";

    // 需要时取结果(阻塞等待)
    int result = fut.get();
    std::cout << "[Main] 结果: " << result << '\n';
    return 0;
}

启动策略和自动推导

返回值的类型推导: std: : async 会自动推导返回值类型,而且会在异步任务完成后把结果存储在std: :future 对象里。这种类型推导可以让代码更加简洁,不需要明确指定返回值类型。

// 策略1:强制新线程(和 std::thread 行为类似,但多了 future)
auto f1 = std::async(std::launch::async, func);

// 策略2:延迟执行(lazy),直到你调用 fut.get() 或 fut.wait() 时才在当前线程同步执行
auto f2 = std::async(std::launch::deferred, func);

// 策略3:系统决定(默认),可能新线程也可能延迟执行
auto f3 = std::async(func);  // 等价于 std::launch::async | std::launch::deferred

注意:async 的任务的生命周期问题

异步任务的生命周期与std: :future 对象绑定。如果future 对象被销毁,那么异步任务也会被取消。所以,确保future 对象的生命周期覆盖任务的执行时间。

// 错误写法:返回的 future 被丢弃!
std::async(std::launch::async, heavy_compute, 21);  // 临时 future 被销毁

// 后果:析构函数会阻塞,直到任务完成!
// 原因:std::async 返回的 future 析构时会隐式 join
// 这意味着上面的代码本质上是同步的,完全失去了异步的意义

正确写法:必须把 future 存起来。

析构阻塞的精确范围

future 析构会阻塞这件事只对 std::async 创建的那块共享状态成立,因为只有它内部封装了thread,自动join等待结果阻塞线程。

promise / packaged_task 因为需要手动启动thread,产生的 future 析构时绝不阻塞和 join。

// 会阻塞:async 的 future 析构隐式 join
{
    std::async(std::launch::async, heavy_compute, 21);  // 析构 = 同步等待
}
// 循环踩坑
{
    for (int i = 0; i < 10; ++i)
        std::async(std::launch::async, work, i);  // 每次迭代末尾临时 future 析构 → 串行!
}
// 你以为启动了 10 个并行任务,实际是跑完一个再跑下一个
// 正确做法
{
    vector<std::future<int>> futs;
    for (int i = 0; i < 10; ++i)
        futs.push_back(std::async(std::launch::async, work, i));
}

// 不会阻塞:packaged_task 的 future 析构什么也不做
{
    std::packaged_task<int(int)> task(heavy_compute);
    auto fut = task.get_future();
    // fut 析构:不阻塞;但 task 若未执行会触发 broken_promise(见第二节)
}

记忆:只有 async 的 future 析构会隐式 join;promise / packaged_task 的 future 析构不阻塞。

并发工程实践

虽然std: : async提供了一种简单实用的并发机制,但在实际应用中,你可能还需要使用其他并发容器如std: :mutex、 std: :lock_guard、 std: :atomic等来处理复杂的共享数据访问问题。


五、std::shared_future:一个生产者、多个消费者

前面所有示例都是一个写端、一个读端。但真实场景经常是多个消费者等待同一个结果(例如多个线程都要等同一个配置加载完成)。这时把普通 future 转成 std::shared_future:

  • shared_future 可以拷贝,可以多线程同时 get(),每个调用者都拿到值的拷贝(对左值则拿到 const 引用)。
  • 由 std::future::share() 得到;也可以直接把 future move 构造进 shared_future。
#include <future>
#include <thread>
#include <vector>
#include <iostream>

int load_config() { /* 耗时初始化 */ return 100; }

int main() {
    std::future<int> fut = std::async(std::launch::async, load_config);

    // 转成 shared_future:可被多个线程共享读取
    std::shared_future<int> sf = fut.share();   // fut 之后变 invalid

    std::vector<std::thread> ts;
    for (int i = 0; i < 4; ++i) {
        ts.emplace_back([&sf, i] {
            int cfg = sf.get();   // 每个线程都能 get 一次,拿到同一份值
            std::cout << "thread " << i << " got " << cfg << '\n';
        });
    }
    for (auto& t : ts) t.join();
}

要点:普通 future 是独占、一次性的读端;shared_future 是可共享、可重复读的读端。需要广播一个结果给多方时用它,而不是反复 get_future()(那是非法的)。


六、四种不同使用方式的完整实践案例

模拟一个真实场景:主线程派发多个独立任务 → 工作线程各自计算 → 主线程收集结果。分别用四种方式实现同一件事,对比写法差异。

#include <iostream>
#include <vector>
#include <thread>
#include <future>
#include <numeric>
#include <chrono>

// 模拟耗时任务
int task(int id) {
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    return id * 10;
}

int main() {
    const int N = 5;

    // ===== 方式1:async(最简,推荐日常使用)=====
    {
        std::vector<std::future<int>> futs;
        futs.reserve(N);
        for (int i = 0; i < N; ++i)
            futs.push_back(std::async(std::launch::async, task, i));

        int sum = 0;
        for (auto& f : futs) sum += f.get();
        std::cout << "async 结果: " << sum << '\n';
    }

    // ===== 方式2:packaged_task + thread =====
    {
        std::vector<std::future<int>> futs;
        std::vector<std::thread> threads;
        futs.reserve(N);  threads.reserve(N);
        for (int i = 0; i < N; ++i) {
            std::packaged_task<int(int)> pt(task);
            futs.push_back(pt.get_future());
            threads.emplace_back(std::move(pt), i);
        }
        int sum = 0;
        for (auto& f : futs) sum += f.get();
        for (auto& t : threads) t.join();
        std::cout << "packaged_task 结果: " << sum << '\n';
    }

    // ===== 方式3:promise + thread(手写投递 + 异常兜底)=====
    {
        std::vector<std::future<int>> futs;
        std::vector<std::thread> threads;
        futs.reserve(N);  threads.reserve(N);
        auto worker = [](int id, std::promise<int> p) {
            try {
                p.set_value(task(id));   // promise 按值传入(move 进 lambda)
            } catch (...) {
                p.set_exception(std::current_exception());  // 防止 broken_promise
            }
        };
        for (int i = 0; i < N; ++i) {
            std::promise<int> prom;
            futs.push_back(prom.get_future());
            threads.emplace_back(worker, i, std::move(prom));
        }
        int sum = 0;
        for (auto& f : futs) {
            try { sum += f.get(); }
            catch (const std::future_error& e) { std::cerr << "任务异常: " << e.what() << '\n'; }
        }
        for (auto& t : threads) t.join();
        std::cout << "promise 结果: " << sum << '\n';
    }

    // ===== 方式4:future 的其他成员(wait_for / valid)=====
    {
        auto f = std::async(std::launch::async, task, 99);
        while (f.valid()) {
            auto st = f.wait_for(std::chrono::milliseconds(100));
            if (st == std::future_status::ready) {
                std::cout << "结果就绪: " << f.get() << '\n';
                break;
            } else if (st == std::future_status::timeout) {
                std::cout << "还没好,先干点别的...\n";
            }
        }
    }
}

注:方式3 中 promise 必须使用移动语意传进 worker(std::move(prom)),lambda 不能按值捕获 promise,否则编译失败;worker 内 try/catch 保证异常分支也 set_exception,避免对面读到 broken_promise。另外 sum += f.get() 是串行等待——第一个任务最慢时,后面早就绪的任务也得干等,生产环境可先 wait_for(0s) 过滤已就绪的,或用 C++20 when_all。


七、一张表总结选型

需求选谁
只要异步拿个返回值,懒得管线程std::async(记得存 future)
要把任务丢进线程池 / 自己控制何时何地执行packaged_task
结果不是函数返回值(回调、事件、中途算出的值)promise
一个结果要广播给多个消费者shared_future(fut.share())
只需要等待、不需要值std::future\ 或直接用条件变量
要等多个任务中任意一个完成C++20 std::when_any / 自己封装
要等全部完成并汇总C++20 std::when_all

一句话记忆:async 包 packaged_task,packaged_task 内置 promise,promise 和 future 共享一块状态;读端默认独占且一次性,要共享就 share()。


八、五个常见异常速查(future_error 错误码)

错误码触发条件常见场景
broken_promisepromise 析构时从未 set_value / set_exceptionworker 提前 return 或抛未捕获异常
future_already_retrieved对同一 promise / packaged_task 调第二次 get_future()想给多个消费者 → 应改用 shared_future
promise_already_satisfiedset_value / set_exception 调了第二次写端重复投递
no_state在没有关联共享状态的 promise / future 上操作move 走之后又用原对象;二次 get()
二次 get()同一 future get() 两次第二次属未定义行为或抛异常,应改用 shared_future

编译提示:以上均需 -pthread;std::when_all / std::when_any 为 C++20 特性,各标准库实现进度不一。