努力努力再努力wz头像
【C++高阶系列】异步任务的结果怎么回来?一文理解 future、async、promise、shared_future 与 packaged_task封面图

【C++高阶系列】异步任务的结果怎么回来?一文理解 future、async、promise、shared_future 与 packaged_task

🔥 本文专栏:C++高阶
🌸作者主页:努力努力再努力wz

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

💪 今日博客励志语录:能力不是某一天突然拥有的,它只是无数个“我今天再搞懂一点”叠加出来的。


思维导图

单线程顺序执行
        ↓
某个函数执行时间很长
        ↓
后续逻辑并不依赖当前函数结果
        ↓
没有必要让当前执行流一直等待
        ↓
把耗时任务交给另一个执行流
        ↓
main 线程继续处理其他逻辑
        ↓
新的问题出现:
另一个执行流的结果怎么传回来?
        ↓
传统方案
        ↓
thread
+
共享结果 result
+
完成状态 ready
+
mutex
+
condition_variable
        ↓
可以解决问题
但“任务执行 + 结果同步”需要程序员手动组织
        ↓
C++11 <future> 提供更高层的异步结果抽象
        ↓
                shared state
               /            \
              /              \
        Producer            Consumer
              |              |
   async / promise /     future /
   packaged_task         shared_future
        ↓
std::async
        ↓
“任务执行 + 结果写入 + future”整体封装
        ↓
std::promise
        ↓
任务如何执行由程序员控制
只把结果写入 shared state 的能力暴露出来
        ↓
std::packaged_task
        ↓
进一步把
“执行 callable + 捕获结果/异常 + 写 shared state”
封装成一个任务对象
        ↓
线程池
        ↓
任务去程:
提交线程 → packaged_task → Task Queue → Worker

结果回程:
Worker → shared state → future → 提交线程

引入

在学习 std::future、std::async、std::promise、std::shared_future 和 std::packaged_task 之前,如果直接从这些 API 的函数声明开始:

std::async(...)
future.get()
promise.set_value(...)
future.share()
packaged_task(...)

很容易出现一个问题:

每一个接口似乎都能看懂,但是不知道这些东西为什么会被设计出来,也不知道它们和我们此前学习的 std::thread、互斥锁以及条件变量之间到底是什么关系。

因此这里并不准备直接从 API 列表开始,而是先回到一个最基本的并发场景。

假设当前只有一个线程,其中需要依次完成多个动作:

Task A
  ↓
Task B
  ↓
Task C
  ↓
Task D

单线程执行流天然具有顺序性。

也就是说:

A 没执行结束
    ↓
B 就不能开始

B 没执行结束
    ↓
C 就不能开始

如果这些动作都很快,这并没有什么问题。

但是如果其中:

Task A

是一个非常耗时的操作,而后面的:

Task B
Task C
Task D

又完全不依赖 Task A 的执行结果,那么让当前线程一直停留在 Task A 上等待,就会产生明显的执行流浪费。

于是整个问题就自然演变成:

能不能把这个耗时任务交给其他执行流处理,而当前线程继续完成后续逻辑,等真正需要结果时再回来获取?

这就是理解 C++ future 体系最合适的切入点。


一、先从同步执行开始

假设存在一个耗时函数:

int calculate()
{
    // 假设内部执行时间很长
    return 100;
}

最普通的调用方式:

int result = calculate();

doSomething();

整个执行流就是:

main 线程
    |
    | calculate()
    |
    | 执行耗时计算
    |
    | return 100
    |
    v
doSomething()

这里 calculate() 没有返回之前:

main

不会继续执行后面的 doSomething()。

在当前语境下,可以将这种行为理解为:

同步调用

也就是:

当前执行流发起一个动作以后,要等这个动作完成并得到结果,才能继续向后推进。

如果:

calculate() = 5 秒

那么 main 线程就会在这里停留大约 5 秒。


二、当后续逻辑不依赖当前结果时,可以把任务拆出去

现在假设:

calculate()

虽然需要 5 秒,但是 main 后面要做的事情:

prepareData();
printLog();
handleOtherWork();

完全不依赖 calculate() 的结果。

那么更合理的执行结构就是:

                    calculate()
                   /
main 线程 --------+
   |               \
   |                其他执行流继续计算
   |
   | prepareData()
   |
   | printLog()
   |
   | handleOtherWork()
   |
   | 真正需要结果
   v
获取 calculate() 的结果

这里就从:

同步

过渡到了:

异步

可以先建立一个简单认知:

异步并不是“任务不用执行了”,而是任务不再要求沿着当前执行流同步完成。

当前线程负责:

发起任务

而任务真正的执行可以由其他执行流完成。

但是新的问题马上出现:

任务虽然交给别人执行了,结果最终怎么回来?

这才是整个 future 体系真正要解决的问题。


三、没有 future 以前,我们完全可以自己解决

在没有使用 future 这些高层抽象以前,最直接的办法就是:

std::thread
+
共享资源
+
mutex
+
condition_variable

例如:

#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>

int calculate()
{
    return 100;
}

int main()
{
    int result = 0;
    bool ready = false;

    std::mutex mtx;
    std::condition_variable cv;

    std::thread worker([&]() {
        int temp = calculate();

        {
            std::lock_guard<std::mutex> lock(mtx);

            result = temp;
            ready = true;
        }

        cv.notify_one();
    });

    // main 可以继续执行其他逻辑
    doSomething();

    {
        std::unique_lock<std::mutex> lock(mtx);

        cv.wait(lock, [&]() {
            return ready;
        });

        std::cout << result << '\n';
    }

    worker.join();
}

这里实际上存在两个不同的问题。

第一个问题:

计算结果放在哪里?

所以需要:

int result;

第二个问题:

消费者怎么知道 result 已经可以读取?

所以又需要:

bool ready;
std::mutex;
std::condition_variable;

整个模型就是:

                     worker
                        |
                   calculate()
                        |
                   result = 100
                        |
                        v
               +----------------+
               | 共享状态        |
               | result = 100   |
               | ready = true   |
               +----------------+
                        |
                   notify_one()
                        |
                        v
main 线程 ----------> wait()
                        |
                        v
                    读取结果

也就是说,在这种写法中:

我们实际上是在自己搭建一条“异步结果通道”。


四、future 并不是一种全新的并发原理

初次接触 future 时很容易形成一个误区:

thread + mutex + condition_variable
                ↓
旧方案

future + async
                ↓
一种全新的、更先进、更高性能的机制

这种理解并不准确。

future 体系解决的仍然是:

任务异步执行
+
结果如何跨执行流传递
+
消费者如何等待任务完成

区别主要在于抽象层次。

原来:

std::thread
共享结果
ready
mutex
condition_variable
异常处理
生命周期

都需要程序员自己组织。

而现在:

std::async
+
std::future

把其中大量同步细节封装起来。

于是程序员面对的接口变成:

提交任务
   ↓
拿到 future
   ↓
继续执行其他工作
   ↓
future.get()

因此需要建立一个非常重要的认识:

future 的主要价值是提供统一、简单的“异步结果”抽象,而不是天然比我们自己编写线程同步代码更快。


五、如果只看性能,future 也不是所谓的“性能神器”

如果两种方案最终都需要为一个任务建立异步执行流,那么真正比较重的成本往往来自:

线程创建
线程调度
上下文切换
任务本身
同步等待

而不是单纯:

future 这个 C++ 对象

future 体系为了提供通用能力,还需要维护:

shared state
结果
异常
完成状态
生命周期
等待机制

因此不能简单认为:

future
一定比
mutex + condition_variable
更快

反过来,如果我们针对一个非常确定的场景自己设计:

固定线程
+
特定数组 / RingBuffer
+
原子索引
+
特定同步策略

理论上的性能上限往往可以更高,因为我们不需要承担通用抽象中所有功能的成本。

但是:

“自己控制”并不等于“自己写的一定更快”。

如果自己设计时出现:

锁粒度过大
频繁竞争
false sharing
频繁唤醒
缓存局部性差
大量上下文切换

最终完全可能比标准库实现更慢。

因此真正的性能思路应该是:

先建立正确结构
    ↓
profiling
    ↓
确定真正热点
    ↓
再决定是否下沉到自定义同步

对于一个本身执行几百毫秒甚至几秒的耗时任务来说,future 带来的一点管理开销通常根本不是主要矛盾。

而对于每秒几十万次、每个任务只有几微秒的高频任务来说,真正应该考虑的也往往不是:

future
vs
condition_variable

而是:

线程池
任务队列
批处理
减少线程创建
减少唤醒
减少上下文切换
缓存局部性
原子与无锁结构

六、整个 future 体系真正的核心:shared state

现在开始进入 future 的核心。

假设我们写:

std::future<int> f =
    std::async(std::launch::async, calculate);

这里最重要的并不是:

future 对象本身存着 result

而是标准库会维护一个:

shared state

可以把它理解成一块由标准库管理的:

异步结果控制块。

逻辑上大致包含:

+----------------------------+
|        shared state        |
|                            |
|  ready / not ready         |
|                            |
|  result                    |
|                            |
|  exception                 |
|                            |
|  synchronization state     |
|                            |
+----------------------------+

需要注意:

这只是心智模型,C++ 标准并没有规定实现必须真的存在这些字段,也没有规定内部必须使用 mutex + condition_variable。

标准规定的是行为语义,而不是具体内部实现。


1. shared state 是生产者和消费者之间的桥梁

整个结构可以画成:

Producer                       Consumer
   |                              |
   |                              |
   v                              v
       +--------------------+
       |    shared state    |
       |                    |
       | result / exception |
       | ready state        |
       +--------------------+

生产者负责:

产生结果
    ↓
写入 shared state

消费者负责:

等待 shared state
    ↓
获取结果

因此后面学习:

async
promise
packaged_task

时,实际上都可以问同一个问题:

谁是 shared state 的生产者端?

而:

future
shared_future

则都是消费者端。


2. future 不是 signal 那种“主动通知”

这里还需要区分 Linux 中比较熟悉的信号机制。

Linux signal 更接近:

事件发生
    ↓
内核记录信号
    ↓
目标执行流后续处理信号

而 future 并不是:

任务完成
    ↓
future 主动调用一个回调函数通知 main

它更接近:

任务完成
    ↓
shared state 变成 ready
    ↓
消费者通过 future
进行 wait / get / timed wait

所以:

signal

偏向事件通知。

而:

future

更偏向:

等待
+
同步
+
结果传递
+
异常传播

七、std::future 到底是什么

std::future 是定义在:

#include <future>

中的类模板。

可以先粗略理解为:

template<class T>
class future;

这里:

T

表示:

异步操作正常完成以后产生的“值结果类型”。

例如:

int calculate()
{
    return 100;
}

对应:

std::future<int>

而:

std::string getName()
{
    return "wangz";
}

对应:

std::future<std::string>

整个类型关系就是:

callable(args...)
        ↓
最终结果类型 T
        ↓
std::future<T>

1. future

如果任务没有返回值:

void saveFile()
{
    // ...
}

仍然可以:

std::future<void> f =
    std::async(std::launch::async, saveFile);

这里并不是:

shared state 里保存了一个 void 对象

而是:

虽然没有值结果需要返回,但是消费者仍然关心任务什么时候完成,以及任务是否抛出了异常。

所以:

f.get();

依然有意义。

它表达:

等待任务完成
+
如果存在异常则重新抛出

八、future 更像一个“异步结果句柄”

可以把:

std::future<int> f;

理解成:

future f
   |
   | 关联
   v
shared state

future 本身不是那个 shared state。

更加准确地说:

future 是消费者访问 shared state 的句柄。

类似我们理解文件描述符时:

fd
 |
 v
内核对象

这里也可以形成:

future
   |
   v
shared state

当然,这只是抽象类比,并不是说 future 内部真的保存了一个整数文件描述符。


九、future 为什么不能复制,而且 get() 只能调用一次

普通:

std::future<T>

采用的是:

单消费者
+
一次性消费

语义。

这点可以借助 std::unique_ptr 来理解。

假设异步任务返回:

std::unique_ptr<int> create()
{
    return std::make_unique<int>(100);
}

那么:

std::future<std::unique_ptr<int>> f =
    std::async(std::launch::async, create);

shared state 最终承载的是:

unique_ptr<int>

当:

auto ptr = f.get();

发生时,结果需要交给唯一消费者。

概念上:

shared state
     |
     | move result
     v
future.get()
     |
     v
ptr

第一次结果已经被拿走以后,就不存在第二个相同的独占资源可以继续拿。

为了让 future<T> 对各种 T 都具有统一语义,标准并没有设计:

future<int>
可以 get 多次

future<unique_ptr<int>>
只能 get 一次

而是统一采用:

一个普通 future 对应一次结果消费。

因此:

auto value = f.get();

以后:

f.valid() == false;

这里更准确的理解是:

get() 之前:

future f
   |
   v
shared state


get() 之后:

future f
   X

不再关联原来的 shared state

可以把它心智模型化成:

内部关联被置空

但这依然只是模型,并不代表实现内部一定真的有一根裸指针被赋值为 nullptr。


1. 为什么只能 move,不能 copy

如果允许:

std::future<int> f2 = f1;

就会出现:

               shared state
                 /      \
                /        \
              f1          f2

那么:

谁拥有最终结果的独占消费权?

尤其当结果是:

std::unique_ptr<int>

时,这个问题更加明显。

因此普通 future:

不能 copy
可以 move

移动表达的不是:

复制一张结果凭证

而是:

把消费权转移给另外一个 future

例如:

std::future<int> f2 = std::move(f1);

之后:

f1
 X

f2
 |
 v
shared state

这和:

std::unique_ptr

在“独占语义”上非常相似。


十、std::async:把任务执行和 future 结果通道一起封装

有了 future 以后,再看:

std::async

就很自然了。

std::async 本身是函数模板,可以先把常用形式理解成:

std::async(
    policy,
    callable,
    args...
);

其中分别表示:

policy
    ↓
怎么执行任务

callable
    ↓
执行什么任务

args...
    ↓
给任务传什么参数

例如:

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

auto f = std::async(
    std::launch::async,
    add,
    10,
    20
);

可以直接翻译成人话:

按照 launch::async 策略
执行 add(10, 20)

由于:

add(10, 20)

返回:

int

所以:

std::async(...)

返回:

std::future<int>

即:

callable(args...)
      ↓
结果类型
      ↓
future<结果类型>

在 C++17 及之后的标准描述中,这个结果类型可以用 std::invoke_result_t 来表达。


十一、std::launch 是什么

std::launch 是一个作用域枚举类型,同时具备 BitmaskType 语义,用于描述 std::async 的启动策略。

我们当前最需要认识两个值:

std::launch::async
std::launch::deferred

1. launch::async

std::async(
    std::launch::async,
    func
);

表示:

任务在与调用方不同的线程执行流中异步执行。

可以建立:

main                         async execution
 |                                |
 | std::async()                   |
 |------------------------------->|
 |                                |
 |                         func()
 |                                |
 v                                |
继续执行                          |

2. launch::deferred

std::async(
    std::launch::deferred,
    func
);

采用的是延迟执行。

也就是:

调用 async
    ↓
暂时不执行 func
    ↓
保存任务
    ↓
第一次通过非定时等待真正请求结果
    ↓
在执行等待操作的线程中执行 func

例如:

auto f = std::async(
    std::launch::deferred,
    calculate
);

f.get();

calculate() 可能直到:

f.get();

时才真正执行。


3. 不显式指定 policy

如果直接:

std::async(func);

默认语义相当于允许:

launch::async
|
launch::deferred

由实现选择具体策略。

所以在学习阶段,为了让执行模型足够明确,可以优先写:

std::async(
    std::launch::async,
    func
);

十二、async 背后真正封装了什么

假设:

int calculate()
{
    return 100;
}

然后:

auto f =
    std::async(
        std::launch::async,
        calculate
    );

从心智模型上,可以把异步执行逻辑理解成:

void async_wrapper()
{
    try
    {
        int result = calculate();

        // 写入 shared state
        store_result(result);

        // 标记 ready
        mark_ready();
    }
    catch (...)
    {
        // 保存异常
        store_exception(std::current_exception());

        // 标记 ready
        mark_ready();
    }
}

这当然不是标准库源码,只是在描述语义。

也就是说:

std::async
    ↓
安排异步任务
    ↓
标准库包装逻辑
    ↓
调用用户 callable
    ↓
      +----------------+
      |                |
   return            throw
      |                |
      v                v
保存 result       保存 exception
      |                |
      +-------+--------+
              |
              v
       shared state ready
              |
              v
           future

所以:

异步执行流真正执行的不只是用户函数本体,还包括标准库围绕结果和异常建立的包装逻辑。


十三、future 最核心的接口

std::future<T> 的主要接口都围绕:

“我如何观察、等待和消费 shared state?”

展开。

最常见的有:

valid()
wait()
wait_for()
wait_until()
get()
share()

1. get():等待 + 消费结果

int result = f.get();

可以直接理解成:

结果 ready 了吗?
      |
   +--+--+
   |     |
  否     是
   |     |
等待     |
   |     |
   +--+--+
      |
      v
   获取结果

所以:

get()
≈
wait until ready
+
consume result

如果 shared state 保存的是异常:

future.get()
    ↓
重新抛出该异常

因此 future 还承担了跨异步执行边界传播异常的作用。


2. wait():只等待,不消费结果

f.wait();

只表示:

等到 shared state ready

不会把结果取出来。

因此可以:

f.wait();

int result = f.get();

此时:

wait()
= 只等待

get()
= 等待 + 消费

3. valid():判断是否仍然关联 shared state

f.valid();

非常容易被误解成:

任务完成了吗?

实际上它问的是:

当前 future 是否仍然关联某个 shared state?

所以:

valid
≠
ready

例如异步任务还没完成:

shared state
not ready

但是:

future
   |
   v
shared state

关联还存在,那么:

f.valid() == true;

而调用:

f.get();

完成一次消费以后:

f.valid() == false;

4. wait_for():等待一段相对时间

例如:

auto status =
    f.wait_for(std::chrono::seconds(2));

表示:

从现在开始
最多等待 2 秒

结果使用:

std::future_status

描述,常见情况包括:

std::future_status::ready
std::future_status::timeout
std::future_status::deferred

即:

ready
    ↓
结果准备完成

timeout
    ↓
等待时间耗尽

deferred
    ↓
关联的是延迟执行任务

5. wait_until():等待到某个时间点

wait_for():

再等 2 秒

属于:

相对时间

而:

f.wait_until(time_point);

表示:

等到某个具体时间点

属于:

绝对时间点

6. share():把独占 future 转换为 shared_future

auto sf = f.share();

表示:

future f
   |
   v
shared state

转换为:

shared_future sf
       |
       v
shared state

而原来的:

f

会失去关联:

f.valid() == false;

十四、一个完整的 async + future 示例

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

int calculate(int a, int b)
{
    std::cout << "异步任务开始执行\n";

    std::this_thread::sleep_for(
        std::chrono::seconds(3)
    );

    std::cout << "异步任务执行完成\n";

    return a + b;
}

int main()
{
    std::future<int> f =
        std::async(
            std::launch::async,
            calculate,
            10,
            20
        );

    std::cout << "main 继续处理其他工作\n";

    std::this_thread::sleep_for(
        std::chrono::seconds(1)
    );

    std::cout << "main 其他工作结束\n";

    int result = f.get();

    std::cout << "result = "
              << result
              << '\n';

    return 0;
}

整个时间线可以理解成:

时间      main                        async task

0s        std::async()
             |----------------------> calculate()
             |
             v
          其他工作                      计算中
             |
1s        其他工作完成                  计算中
             |
             v
          f.get()
             |
             | 等待
             |
3s           |                        return 30
             |                            |
             |                     shared state
             |                      result = 30
             |                      ready = true
             |
             v
          得到 30

这里正好体现:

get() 是否阻塞,取决于调用它时 shared state 是否已经 ready。

如果 main 自己的其他工作需要 5 秒,而异步任务 3 秒就结束:

3s
shared state 已经 ready

5s
main 才调用 get()

那么:

f.get();

就可以直接取得结果,不需要继续等待。


十五、std::promise 为什么出现

std::async 非常方便,但是它把生产者侧很多行为都封装了。

我们只需要:

std::async(func);

标准库负责:

任务怎么启动
    ↓
执行 callable
    ↓
捕获返回值
    ↓
捕获异常
    ↓
写 shared state

但是有时候,我们并不希望把任务执行方式交给 async。

例如:

线程我自己创建
线程生命周期我自己控制
任务什么时候执行我自己控制

但是:

结果同步
等待
异常传播

仍然希望使用 future 这一套标准机制。

这就自然引出了:

std::promise<T>

十六、promise:shared state 的生产者端句柄

可以把:

std::promise<int>

理解成:

生产者访问和操作 shared state 的句柄。

而:

std::future<int>

是:

消费者访问 shared state 的句柄。

整个结构:

        Producer                    Consumer
           |                           |
      promise<int>                future<int>
           |                           |
           +------ shared state -------+

因此:

promise
    ↓
push end

future
    ↓
pop / wait end

如果继续借助 Linux 的思想,也可以把它类比成一条一次性结果管道:

              shared state
              /          \
          promise        future
          写入端          读取端

但是它和普通 pipe 不同:

pipe
    ↓
可以连续传很多数据

promise/future
    ↓
主要描述一次异步结果交付

十七、promise 和 async 最大的区别:生产者控制权

使用:

std::async(...)

时,可以理解成:

程序员
   |
   | 提交 callable
   v
async
   |
   +-- 执行任务
   +-- 捕获结果
   +-- 捕获异常
   +-- 写 shared state
   |
   v
future

而:

promise + future

变成:

程序员
   |
   +-- 自己创建 thread
   +-- 自己决定执行逻辑
   +-- 自己决定何时产生结果
   +-- 自己决定何时 set_value()
   |
   v
promise
   |
   v
shared state
   |
   v
future

所以:

promise 并不是线程管理工具,它只是把“生产者如何把结果写入 shared state”这件事单独暴露出来。

线程本身怎么创建,仍然由上层控制。


十八、promise 的核心接口

promise 最重要的接口可以分成两类:

               promise<T>
                    |
        +-----------+-----------+
        |                       |
        v                       v
  获取消费者端              完成 shared state
        |                       |
  get_future()          +-------+--------+
                        |                |
                   set_value()    set_exception()

1. get_future()

std::promise<int> p;

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

创建 promise 以后,可以理解为:

promise p
    |
    v
shared state

然后:

p.get_future();

并不是创建另一个 shared state。

而是:

取得一个同样关联当前 shared state 的消费者端 future。

即:

              shared state
              /          \
             /            \
      promise<int>      future<int>
       Producer          Consumer

同一个 promise 的 get_future() 只能成功获取一次普通 future。


2. set_value()

生产者:

p.set_value(100);

可以理解成:

promise
   |
   | set_value(100)
   v
shared state

result = 100
ready = true

消费者:

int value = f.get();

得到:

100

所以:

promise.set_value()
        ↓
生产结果

future.get()
        ↓
消费结果

正好是一对。


3. set_exception()

如果生产者执行逻辑中发生异常:

try
{
    int result = calculate();

    p.set_value(result);
}
catch (...)
{
    p.set_exception(
        std::current_exception()
    );
}

异常会被存入 shared state。

消费者:

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

即可在 get() 时重新获得异常。

整个过程:

worker
   |
   | throw
   v
catch (...)
   |
   | set_exception()
   v
shared state
   |
   | exception
   v
future.get()
   |
   | rethrow
   v
consumer

十九、promise + thread 完整示例

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

void worker(std::promise<int> p)
{
    try
    {
        std::this_thread::sleep_for(
            std::chrono::seconds(2)
        );

        int result = 10 + 20;

        p.set_value(result);
    }
    catch (...)
    {
        p.set_exception(
            std::current_exception()
        );
    }
}

int main()
{
    std::promise<int> p;

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

    std::thread t(
        worker,
        std::move(p)
    );

    std::cout
        << "main 继续执行其他逻辑\n";

    try
    {
        int result = f.get();

        std::cout
            << "result = "
            << result
            << '\n';
    }
    catch (const std::exception& e)
    {
        std::cout
            << e.what()
            << '\n';
    }

    t.join();

    return 0;
}

这里:

std::move(p)

是因为:

promise
不能 copy
可以 move

可以理解成:

shared state 的生产端控制权也采用独占式语义。

所以:

main
    |
    | move promise
    v
worker

并不是复制第二个生产端,而是把生产者句柄交给 worker。


二十、promise 相比最原始方案,到底帮我们封装了多少

这里很容易产生一个新的误区:

用了 promise
    ↓
生产者一侧就全部由标准库管理了

并不是。

promise 只是把:

shared state
结果同步
等待与通知
异常传播
生命周期管理

这些细节交给标准库。

程序员依然可以自己控制:

线程怎么创建
任务什么时候执行
任务执行什么
结果什么时候产生
什么时候 set_value()

因此可以按照控制权分成:

控制权少
   ↑

async + future
        |
        v
thread + promise + future
        |
        v
thread + 自定义共享状态 + 自定义同步

   ↓
控制权多

其中:

promise + future

处在一个中间层:

任务执行我自己控制,但是异步结果的同步通道交给标准库。


二十一、std::shared_future 为什么出现

普通:

std::future<T>

解决的是:

一个 shared state
        ↓
一个消费者

但如果出现:

一次异步计算
       ↓
多个线程都需要读取结果

普通 future 就不够用了。

例如:

             shared state
            result = 100
            /    |     \
           /     |      \
       WorkerA WorkerB WorkerC

我们希望:

A 能读取
B 能读取
C 也能读取

这就引出了:

std::shared_future<T>

二十二、future 和 shared_future:独占与共享

这里可以直接借助:

unique_ptr
vs
shared_ptr

建立认知。

当然这里只是:

所有权 / 访问语义类比

并不是说它们底层实现相同。

普通:

std::future<T>

更像:

独占结果消费权

因此:

不能 copy
只能 move
get 一次

而:

std::shared_future<T>

表示:

共享结果访问权

因此:

可以 copy
多个 shared_future
可以关联同一个 shared state
get 可以多次调用

结构:

                shared state
               result = 100
              /      |      \
             /       |       \
           sf1      sf2      sf3

然后:

sf1.get();
sf2.get();
sf3.get();

都可以读取同一个结果。


二十三、为什么 shared_future::get() 不能把结果 move 走

这也是共享语义必然推导出来的结果。

如果:

sf1.get()

直接把 shared state 中的结果:

move 出去

那么:

sf2
sf3

后面还读取什么?

所以对于普通:

std::shared_future<T>

它的:

get()

返回:

const T&

也就是:

多个消费者共同读取 shared state 中保存的结果,而不是由某一个消费者独占地把结果搬走。

例如:

std::shared_future<std::string> sf;

读取时:

const std::string& value = sf.get();

逻辑上:

               "hello"
                   ^
                   |
             shared state
             /    |     \
            /     |      \
          sf1    sf2     sf3
           |      |       |
        const&  const&   const&

而普通:

future<T>::get()

对于值类型返回 T,语义上会把 shared state 中的值以移动方式交给唯一消费者。


二十四、如何得到 shared_future

普通 future 提供:

share()

例如:

std::future<int> f =
    std::async(
        std::launch::async,
        calculate
    );

std::shared_future<int> sf =
    f.share();

调用前:

future f
   |
   v
shared state

调用以后:

future f
   X


shared_future sf
       |
       v
shared state

因此:

f.valid() == false;
sf.valid() == true;

之后:

std::shared_future<int> sf2 = sf;
std::shared_future<int> sf3 = sf;

就可以得到:

             shared state
             /    |     \
            /     |      \
          sf      sf2     sf3

二十五、std::packaged_task 为什么出现

认识 promise 以后,再理解:

std::packaged_task

就会非常自然。

使用 promise 时:

执行 callable
      ↓
得到 result
      ↓
自己调用 promise.set_value(result)

如果发生异常:

catch (...)
      ↓
自己调用 promise.set_exception(...)

也就是说:

promise 虽然给了我们生产者控制权,但是“调用函数 → 取得结果 → 写 shared state”这一段仍然需要程序员手动组织。

于是就产生了新的抽象需求:

能不能把一个 callable 和 shared state 直接绑定,让调用这个任务对象时,返回值和异常自动进入 shared state?

这就是:

std::packaged_task

二十六、packaged_task 的核心模型

可以把:

std::packaged_task

理解成:

一个带有 future/shared-state 结果通道的 callable 包装器。

假设:

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

包装:

std::packaged_task<int(int, int)>
    task(add);

结构可以理解成:

          packaged_task
          /           \
         /             \
    callable        shared state
      add

然后:

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

得到:

                  shared state
                  /          \
                 /            \
        packaged_task        future
          Producer          Consumer

真正执行:

task(10, 20);

时,内部完成:

task(10, 20)
      ↓
add(10, 20)
      ↓
return 30
      ↓
自动写 shared state
      ↓
ready

随后:

int result = f.get();

即可获得:

30

二十七、packaged_task 的模板参数为什么长这样

future:

std::future<int>

只需要知道:

最终结果类型

而 packaged_task 还需要知道:

任务接受什么参数
任务返回什么类型

因此使用完整函数签名:

std::packaged_task<R(Args...)>

例如:

std::packaged_task<int(int, int)>

表示:

参数:
int
int

返回:
int

它和:

std::function<R(Args...)>

的模板形式非常像。

例如:

std::function<int(int, int)>
std::packaged_task<int(int, int)>

但是二者职责不同。

std::function:

保存 callable
     ↓
调用 callable
     ↓
结果直接返回给调用者

而 std::packaged_task:

保存 callable
     ↓
调用 callable
     ↓
结果 / 异常
     ↓
shared state
     ↓
future

二十八、packaged_task 并不负责创建线程

这一点非常重要。

如果:

std::packaged_task<int(int, int)>
    task(add);

auto f = task.get_future();

task(10, 20);

那么:

当前线程
   ↓
task()
   ↓
add()

仍然是当前线程自己同步执行。

packaged_task 并不会因为名字里有 task 就自动建立新的执行流。

如果希望 worker 执行:

std::thread t(
    std::move(task),
    10,
    20
);

才会形成:

main
  |
  | move task
  v
worker
  |
  | task(10,20)
  v
add(10,20)
  |
  v
shared state

所以:

packaged_task 管的是“任务结果如何进入 shared state”,而不是“由哪个线程执行这个任务”。


二十九、promise 和 packaged_task 到底差在哪里

可以直接放在一起看。

promise

你自己调用 callable
       ↓
你自己拿 result
       ↓
你自己 set_value()
       ↓
shared state

也就是说:

任务执行逻辑
你管

结果写入时机
你管

packaged_task

调用 task()
    ↓
packaged_task
    ↓
调用内部 callable
    ↓
捕获 result / exception
    ↓
自动写 shared state

因此:

任务由谁执行
你管

什么时候执行 task()
你管

callable 的结果怎么进入 shared state
packaged_task 管

所以它相当于:

在 promise 的基础上,进一步把“执行 callable → 捕获结果/异常 → 完成 shared state”这一段生产者逻辑封装起来。


三十、async、promise、packaged_task 的控制权关系

现在可以把三个生产者方案放在一起。

控制权更多
   ↑

自定义 thread + 自定义同步
        |
        v
thread + promise + future
        |
        v
thread / scheduler + packaged_task + future
        |
        v
async + future

   ↓
封装程度更高

更具体来说:

---------------------------------------------------------------
方案                 线程/调度     调用任务     写结果/异常
---------------------------------------------------------------
自定义同步             自己           自己           自己
promise + future       自己           自己           自己
packaged_task          自己           自己决定时机    自动
async + future         库负责较多     库负责          自动
---------------------------------------------------------------

这里没有:

谁一定更优秀

只有:

不同的控制粒度

三十一、packaged_task 的经典应用:线程池

packaged_task 最经典的场景之一就是:

线程池

线程池本身是一个非常典型的生产者—消费者模型。

提交任务的一方:

Producer

不断向任务队列放任务。

worker:

Consumer

不断从任务队列中取任务执行。

基本结构:

提交线程
   |
   | push task
   v
+------------------+
|    Task Queue    |
+------------------+
   |      |      |
   v      v      v
worker1 worker2 worker3

任务队列本身属于共享资源,因此:

push
pop

之间仍然需要自己的并发控制。

可以使用:

mutex + condition_variable

也可以根据自己的实现选择:

数组
RingBuffer
atomic index
semaphore
无锁 / 低锁结构

这一层和 packaged_task 没有直接关系。


三十二、线程池里其实存在两套“生产者—消费者”

这里非常容易混淆。

第一套:任务流

从任务队列角度:

提交线程
    ↓
生产任务
    ↓
Task Queue
    ↓
worker
    ↓
消费任务

所以:

提交线程
=
任务生产者

worker
=
任务消费者

第二套:结果流

当 worker 真正执行任务以后:

worker
    ↓
产生 result
    ↓
shared state
    ↓
future
    ↓
提交线程

这时候:

worker
=
结果生产者

提交线程
=
结果消费者

所以线程池存在两条方向相反的数据流:

                 任务流

提交线程  --------------------->  worker
           packaged_task


                 结果流

提交线程  <---------------------  worker
  future        shared state

这也是 packaged_task + future 在线程池中非常自然的原因。


三十三、线程池里的 packaged_task 生命周期

提交者创建:

callable
   ↓
packaged_task

然后:

auto future = task.get_future();

future 留在提交者手里。

而:

packaged_task

所有权则向 worker 移动:

提交者
   |
   | move
   v
Task Queue
   |
   | move / pop
   v
Worker

于是整体:

提交线程

callable
   |
   v
packaged_task --------------------+
   |                              |
   | get_future()                 |
   v                              |
future                            |
   |                              |
   |                       Task Queue
   |                              |
   |                          worker pop
   |                              |
   |                           task()
   |                              |
   |                        callable()
   |                              |
   |                       result/exception
   |                              |
   |                       shared state
   |                              |
   +------------------------------+
   |
future.get()

所以:

Task Queue 负责“任务怎么过去”,shared state + future 负责“结果怎么回来”。

可以记成:

任务去程
    ↓
Task Queue

结果回程
    ↓
shared state + future

三十四、为什么 packaged_task 在线程池里比手动 promise 更自然

如果在线程池中使用 promise:

worker
   ↓
调用任务函数
   ↓
得到 result
   ↓
promise.set_value(result)

发生异常
   ↓
promise.set_exception(...)

worker 必须参与:

结果类型
返回值
异常处理
结果写入

这些逻辑。

而如果使用:

packaged_task

worker 只需要:

task();

它不需要关心:

任务返回 int?
任务返回 string?
任务返回 unique_ptr?
任务是不是 void?
任务有没有抛异常?
结果最后交给谁?

这些都已经由:

packaged_task
+
shared state
+
future

绑定起来。

这就是它在线程池中的价值。


三十五、不同返回类型的任务怎么放进同一个队列

实际线程池中,用户提交的任务可能是:

int task1();
void task2();
std::string task3();

对应的:

packaged_task<int()>
packaged_task<void()>
packaged_task<std::string()>

类型都不同。

但是任务队列通常希望保存一种统一类型,例如:

std::function<void()>

一个经典思路是:

auto task =
    std::make_shared<
        std::packaged_task<int()>
    >(
        [] {
            return calculate();
        }
    );

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

queue.push(
    [task]() {
        (*task)();
    }
);

这样:

原始任务
    ↓
packaged_task<R()>
    ↓
包装成 void() callable
    ↓
统一进入 std::function<void()> 队列

worker 永远只需要:

job();

而真正的返回值仍然会自动进入对应的 shared state。


三十六、future / promise / packaged_task 的 shared state 关系

现在可以把整个体系压缩成一张图:

                       shared state
                      /            \
                     /              \
              Producer             Consumer
                  |                   |
        +---------+---------+         |
        |         |         |         |
      async    promise  packaged_task |
        |         |         |         |
        +---------+---------+         |
                  |                   |
                  +------------+------+
                               |
                         future
                            /
                           /
                   shared_future

更加准确地说:

async
    ↓
标准库帮你执行 callable
并自动完成 shared state

promise
    ↓
你自己产生结果
再手动 set_value / set_exception

packaged_task
    ↓
你决定何时/在哪里执行 task
task 自动执行 callable
并自动完成 shared state

消费者侧:

future
    ↓
独占式、一次性消费

shared_future
    ↓
共享式、多消费者读取

三十七、把整个体系放回最开始的传统方案

最开始:

std::thread
+
result
+
ready
+
mutex
+
condition_variable

我们自己控制:

任务执行
结果存储
完成状态
加锁
等待
唤醒
异常处理
生命周期

而 future 体系则是在不同位置提供不同抽象。


1. async + future

我只想说:

“执行这个任务,
以后把结果给我。”

于是:

任务执行
结果捕获
异常捕获
shared state
future

基本整体封装。


2. promise + future

线程怎么创建
任务怎么执行
结果什么时候产生

全部自己控制。

但是:

结果怎么安全交给消费者
消费者怎么等待
异常怎么传播

交给标准库。


3. packaged_task + future

进一步在 promise 的基础上把:

callable
    ↓
返回值 / 异常
    ↓
shared state

这段封装起来。

程序员只控制:

什么时候执行 task
在哪个执行流执行 task

4. 自定义同步

如果追求更强控制能力,可以继续下沉:

自定义 Task Queue
自定义共享结构
atomic
mutex
shared_mutex
condition_variable
semaphore
RingBuffer
线程池
绑核
缓存布局

但是代价就是:

正确性、生命周期和同步细节全部重新由程序员自己承担。


三十八、最终应该怎么选择

这里不能形成:

新特性出现
    ↓
旧方案淘汰
    ↓
以后全部使用 future

这种思维。

正确方式应该是:

先看需求
    ↓
需要多少控制权
    ↓
是否真的处于性能热点
    ↓
选择合适抽象层级

如果只是:

偶尔执行一个真正耗时的独立任务

可以优先考虑:

async + future

因为代码和语义非常直接。

如果:

线程我自己管理
结果产生过程比较复杂

可以使用:

promise + future

如果:

任务本身需要进入队列
未来由 worker 执行
并且提交者需要 future

那么:

packaged_task + future

非常自然。

如果:

一个结果需要多个消费者

则需要:

shared_future

而如果场景已经进入:

高频微任务
超低延迟
固定 worker
自定义调度
无锁队列
CPU affinity
缓存局部性优化

那么真正应该研究的就是:

线程池
Task System
Atomic
Lock-Free
Work Stealing
Batching

而不是简单纠结:

future
vs
condition_variable

三十九、最终心智模型

到这里,可以把这一整套 C++11 异步结果机制压缩成下面这张图。

                    “我要一个异步结果”
                            |
                            v
                      shared state
                     /            \
                    /              \
              Producer           Consumer
                  |                  |
       +----------+----------+       |
       |          |          |       |
     async     promise   packaged_task
       |          |          |
       |          |          |
       |          |          +--> callable + result channel
       |          |
       |          +--> 手动 set_value / set_exception
       |
       +--> 任务执行到结果通道整体封装

                                 Consumer
                                    |
                           +--------+--------+
                           |                 |
                        future         shared_future
                           |                 |
                       独占结果            共享结果
                       只能 move           可以 copy
                       get 一次            get 多次

从更大的并发设计视角看:

std::thread
    ↓
关注“执行流”

mutex / condition_variable / atomic
    ↓
关注“同步机制”

future / shared_future
    ↓
关注“异步结果”

promise
    ↓
关注“手动生产异步结果”

packaged_task
    ↓
关注“任务与异步结果绑定”

async
    ↓
关注“直接提交异步计算”

这也是理解这些 C++ 特性的关键:

它们并不是彼此孤立的几个 API,而是围绕同一个 shared state,在不同抽象层级上给生产者和消费者提供不同程度的控制能力。


四十、总结

本文并没有从 future 的接口开始死记 API,而是先从最基本的问题出发:

单线程中存在耗时任务
        ↓
后续逻辑不依赖当前结果
        ↓
任务可以拆到其他执行流
        ↓
但结果最终还需要回来

于是最传统的方案是:

thread
+
共享资源
+
mutex
+
condition_variable

在此基础上,C++11 引入 <future> 体系,将:

异步结果
完成状态
等待同步
异常传播
生命周期

抽象为:

shared state

并围绕它形成:

生产者:
async
promise
packaged_task

消费者:
future
shared_future

其中:

future

是独占式的一次性结果句柄;

shared_future

是共享式结果句柄;

async

将任务执行和结果通道整体封装;

promise

把生产者结果写入能力单独暴露出来;

packaged_task

则把 callable 和 shared state 绑定成一个可以被线程池调度的任务对象。

最后再回到工程实践:

学习这些特性并不是为了见到新 API 就立即替换掉 thread、mutex、condition_variable,而是理解每一层抽象替我们做了什么、牺牲了什么,以及在自己的场景里到底需要多少控制权。

只有把这一层关系想清楚以后:

future
async
promise
shared_future
packaged_task

才不再是五个零散的 C++ 名词,而是一套完整的:

异步任务结果传递模型。


在这里插入图片描述

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2301_80260194/article/details/167175947

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--