C++ async 与 future

当我们希望让后台执行一项耗时计算,等算完再直接拿回函数的 return 返回值时,可以使用 <future> 头文件中的 std::async 和 std::future。相比需要借助外部变量传回结果的 std::thread,这一对工具获取异步返回值要直接得多。

它们配合工作的过程很像去奶茶店点单:调用 std::async() 相当于下单让后台开始制作,当场返回的 std::future 对象就是手里的 “取餐小票”。主线程拿到小票后可以先去忙别的事情,等需要结果时再凭小票调用 get() 把结果取回来,如下图所示。

std::async 与 std::future 的异步执行机制

C++ async 与 future 基本用法

在 C++ 中,使用 std::async 和 std::future 之前,我们需要先通过 #include <future> 引入头文件。

语法:

#include <future>

std::future<类型> fut = std::async(启动策略, 函数名, 参数...);
类型 结果变量 = fut.get();

说明:

对于上面的语法,我们需要清楚以下 3 个部分的作用:

  • 启动策略:常用的第 1 个参数是 std::launch::async,表示立刻开启一条新线程在后台异步执行函数。
  • std::future<类型>:用来接收 std::async() 返回的 “结果凭证”,尖括号 <> 里的类型与异步函数的返回值类型保持一致。如果异步函数没有返回值(即返回类型为 void),就写成 std::future<void>。
  • get():等待异步任务完成并取出返回值。如果后台已经算完,它会立刻返回结果;如果还没算完,当前线程就会停在这一行等待,直到拿到结果为止。

示例 1:异步计算并获取结果

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

int add(int a, int b) {
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    return a + b;
}

int main() {
    std::future<int> fut = std::async(std::launch::async, add, 10, 20);
    std::cout << "主线程:已发起异步计算,先处理其他工作\n";

    int sum = fut.get();    // 等待并取出返回值
    std::cout << "主线程:拿到计算结果 = " << sum << "\n";
    return 0;
}

运行结果如下。

主线程:已发起异步计算,先处理其他工作
主线程:拿到计算结果 = 30

分析:

在这个例子中,为了模拟后台耗时计算,我们在 add() 内部调用了前面学过的 sleep_for() 让子线程暂停 100 毫秒。主线程调用 std::async() 发起任务后不会停在原地干等,而是立刻拿到 fut 对象,继续往下打印主线程的提示。

直到执行到 int sum = fut.get(); 时,主线程才正式索要结果,等后台返回 30 后将其赋给 sum。日常写代码时,接收返回值的类型 std::future<int> 也可以直接使用 auto 让编译器自动推导。

注意 调用 std::async() 时,小伙伴们一定要定义变量把返回的 std::future 对象接住。如果不接收返回值,生成的临时 future 对象会在当前语句结束时立刻销毁,并在析构时原地等待后台任务跑完,这样就失去了异步并行的效果。

get()、wait() 与 valid()

除了 get() 之外,std::future 还提供了 wait() 和 valid() 两个常用成员函数。在日常开发中,这 3 个函数的职责有明显区别:

  • get():等待并取走结果,且只能调用 1 次,相当于 “凭小票取餐并回收小票”。一旦调用了 get(),结果就会被移走,当前的 future 对象随之失效,不能再调用第 2 次 get()。
  • wait():只等待任务完成,不取走结果。它相当于站在柜台前等后台把任务做完,但不会把结果领走。因此调用 wait() 返回后,我们后面依然可以调用 1 次 get() 取出结果。
  • valid():检查当前 future 对象是否还关联着未取走的有效结果。在调用 get() 之前返回 true;调用 get() 取走结果之后返回 false。

示例 2:future 的状态变化

#include <future>
#include <iostream>

int getVal() {
    return 42;
}

int main() {
    std::future<int> fut = std::async(std::launch::async, getVal);
    std::cout << "刚创建时 valid():" << fut.valid() << "\n";

    fut.wait();
    std::cout << "wait() 后 valid():" << fut.valid() << "\n";

    int val = fut.get();
    std::cout << "取出结果:" << val << "\n";
    std::cout << "get() 后 valid():" << fut.valid() << "\n";
    return 0;
}

运行结果如下。

刚创建时 valid():1
wait() 后 valid():1
取出结果:42
get() 后 valid():0

分析:

fut.wait(); 只是等待 getVal() 执行完毕,并不会消耗掉结果,因此调用后 fut.valid() 依然输出 1(即 true)。而一旦调用了 fut.get() 把 42 取出来,fut 内部保存的状态就被清空了,再次查看 fut.valid() 就变成了 0(即 false)。

因此,如果后面的代码需要多次用到异步计算的结果,小伙伴们一定要在第 1 次调用 get() 时把返回值存进一个普通变量里重复使用,千万不要对同一个 future 对象连续调用 get()。

// 正确方式
int val = fut.get();
std::cout << val << ", " << val * 2 << "\n";

// 错误方式
int a = fut.get();
int b = fut.get();

在上面的错误方式中,第 1 次调用 fut.get() 已经把结果取走并使 fut 失效,第 2 次再调用 fut.get() 时就会直接抛出 std::future_error 异常。

捕获异步任务中的异常

在普通子线程中,如果函数抛出了未捕获的异常,整个程序会直接崩溃终止。那如果通过 std::async 启动的异步函数在后台运行出错、抛出了异常,程序会怎么处理呢?

这是 std::future 非常省心的一点:当异步任务抛出异常时,异常不会立刻导致程序崩溃,而是会被 std::future 收集并保存起来。等到主线程调用 get() 去取结果时,这个异常就会在 get() 所在的位置重新抛出,我们可以直接使用熟悉的 try...catch 语句捕获它。

示例 3:捕获异步任务的异常

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

int divide(int a, int b) {
    if (b == 0) {
        throw std::runtime_error("除数不能为 0");
    }
    return a / b;
}

int main() {
    auto fut = std::async(std::launch::async, divide, 10, 0);

    try {
        int res = fut.get();
        std::cout << "计算结果:" << res << "\n";
    } catch (const std::runtime_error& e) {
        std::cout << "捕获异步任务异常:" << e.what() << "\n";
    }
    return 0;
}

运行结果如下。

捕获异步任务异常:除数不能为 0

分析:

在这个例子中,异步函数 divide(10, 0) 因为除数为 0,抛出了标准异常 std::runtime_error。这个异常先被保存在了 fut 关联的共享状态中。当主线程在 try 块内部调用 fut.get() 时,异常被原样重新抛出,并由下方的 catch 块安全捕获。

async 的两种启动策略

在前面的示例中,我们在调用 std::async() 时都在第 1 个参数位置传入了 std::launch::async。实际上,C++ 标准库一共提供了 2 种启动策略:

  • std::launch::async:异步执行策略。调用 std::async() 时立刻开启一条新线程,在后台并发执行任务。
  • std::launch::deferred:延迟执行策略,其中 deferred 是 “推迟” 的意思。调用 std::async() 时不创建新线程,也不执行任务,而是一直推迟到后面调用 get() 或 wait() 的那一刻,才在当前线程里把函数执行一遍。

示例 4:deferred 延迟执行

#include <future>
#include <iostream>

int lazyTask() {
    std::cout << " -> 正在执行 lazyTask()\n";
    return 100;
}

int main() {
    std::cout << "1. 创建 deferred 任务\n";
    auto fut = std::async(std::launch::deferred, lazyTask);

    std::cout << "2. 准备调用 fut.get()\n";
    int val = fut.get();

    std::cout << "3. 拿到结果:" << val << "\n";
    return 0;
}

运行结果如下。

1. 创建 deferred 任务
2. 准备调用 fut.get()
 -> 正在执行 lazyTask()
3. 拿到结果:100

分析:

从打印顺序可以清楚地看到:由于创建 fut 时传入了 std::launch::deferred 延迟策略,因此在第 1 步创建任务时,lazyTask() 根本没有运行。对比前面的示例 1,这里一直等到第 2 步之后真正调用 fut.get() 索要结果时,lazyTask() 才在主线程中被触发执行。

需要注意的是,如果在调用 std::async(lazyTask) 时省略了第 1 个策略参数,C++ 标准允许标准库自行决定到底是开启新线程还是推迟执行。为了让程序的并发行为明确可控,在实际开发中,只要希望任务真正在后台异步运行,我们都应该显式写上 std::launch::async。

C++ 常用并发工具选型建议

到这里,我们已经把 C++ 多线程与并发编程中最常用的基础工具都学完了。在实际开发中遇到不同场景时,小伙伴们可以根据下面这 5 条经验来选择:

手动管理线程生命周期:需要启动一条无返回值的后台执行路线时,我们可以使用 std::thread 或 C++20 的 std::jthread。在支持 C++20 的环境下,优先推荐使用自动汇合的 std::jthread。

异步计算并获取返回值:关心后台函数的 return 返回值或异常,且不想手动定义共享变量接结果时,我们可以使用 std::async 配合 std::future。

单个简单变量无锁同步:共享数据只是独立的整数计数器或布尔标志、仅做单步读写或加减时,我们可以优先使用轻便的 std::atomic。

多变量或复杂临界区保护:一次修改涉及多个关联变量、操作容器或包含多步逻辑时,我们应该使用 std::mutex 配合 std::lock_guard 或 std::unique_lock 加锁保护。

等待特定条件成立再执行:需要让线程在条件未满足时睡眠等待、等其他线程改好状态后再发通知唤醒时,我们可以使用 std::condition_variable。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号