欢迎光临
我们一直在努力

【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++ 名词,而是一套完整的:

异步任务结果传递模型。


在这里插入图片描述

赞(0)
未经允许不得转载:171主机测评 » 【C++高阶系列】异步任务的结果怎么回来?一文理解 future、async、promise、shared_future 与 packaged_task
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址