前段时间遇到一个小问题,后来发现这是个挺常见的坑,顺手整理一篇笔记。
🔥 这篇文章专栏:C++高阶 🌸作者主页:努力努力再努力wz
💪 今日博客励志语录:能力不是某一天突然拥有的,它只是无数个“我今天再搞懂一点”叠加出来的。
思维导图
单线程顺序执行
↓
某个函数执行时间很长
↓
后续逻辑并不依赖当前函数结果
↓
没有必要让当前执行流一直等待
↓
把耗时任务交给另一个执行流
↓
main 线程继续处理其他逻辑
↓
新的问题出现:
另一个执行流的结果怎么传回来?
↓
传统方案
↓
thread
+
共享结果 result
+
完成状态 ready
+
mutex
+
condition_variable
↓
可以解决问题
但“任务执行 + 结果同步”需要程序员手动组织
↓
C++11 提供更高层的异步结果抽象
↓
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
#include
#include
#include
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 lock(mtx);
result = temp;
ready = true;
}
cv.notify_one();
});
// main 可以继续执行其他逻辑
doSomething();
{
std::unique_lock lock(mtx);
cv.wait(lock, [&]() {
return ready;
});
std::cout 我们实际上是在自己搭建一条“异步结果通道”。
---
## 四、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 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
中的类模板。
可以先粗略理解为:
template
class future;
这里:
T
表示:
> 异步操作正常完成以后产生的“值结果类型”。
例如:
int calculate()
{
return 100;
}
对应:
std::future
而:
std::string getName()
{
return "wangz";
}
对应:
std::future
整个类型关系就是:
callable(args...)
↓
最终结果类型 T
↓
std::future
---
### 1. future
如果任务没有返回值:
void saveFile()
{
// ...
}
仍然可以:
std::future f =
std::async(std::launch::async, saveFile);
这里并不是:
shared state 里保存了一个 void 对象
而是:
> 虽然没有值结果需要返回,但是消费者仍然关心任务什么时候完成,以及任务是否抛出了异常。
所以:
f.get();
依然有意义。
它表达:
等待任务完成
+
如果存在异常则重新抛出
---
## 八、future 更像一个“异步结果句柄”
可以把:
std::future f;
理解成:
future f
|
| 关联
v
shared state
`future` 本身不是那个 shared state。
更加准确地说:
> future 是消费者访问 shared state 的句柄。
类似我们理解文件描述符时:
fd
|
v
内核对象
这里也可以形成:
future
|
v
shared state
当然,这只是抽象类比,并不是说 `future` 内部真的保存了一个整数文件描述符。
---
## 九、future 为什么不能复制,而且 get() 只能调用一次
普通:
std::future
采用的是:
单消费者
+
一次性消费
语义。
这点可以借助 `std::unique_ptr` 来理解。
假设异步任务返回:
std::unique_ptr create()
{
return std::make_unique(100);
}
那么:
std::future f =
std::async(std::launch::async, create);
shared state 最终承载的是:
unique_ptr
当:
auto ptr = f.get();
发生时,结果需要交给唯一消费者。
概念上:
shared state
|
| move result
v
future.get()
|
v
ptr
第一次结果已经被拿走以后,就不存在第二个相同的独占资源可以继续拿。
为了让 `future<T>` 对各种 `T` 都具有统一语义,标准并没有设计:
future
可以 get 多次
future
只能 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 f2 = f1;
就会出现:
shared state
/ \
/ \
f1 f2
那么:
谁拥有最终结果的独占消费权?
尤其当结果是:
std::unique_ptr
时,这个问题更加明显。
因此普通 `future`:
不能 copy
可以 move
移动表达的不是:
复制一张结果凭证
而是:
把消费权转移给另外一个 future
例如:
std::future 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
即:
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
include
include
include
int calculate(int a, int b)
{
std::cout worker
packaged_task
结果流
提交线程 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
packaged_task
packaged_task
类型都不同。
但是任务队列通常希望保存一种统一类型,例如:
std::function
一个经典思路是:
auto task =
std::make_shared<
std::packaged_task
>(
[] {
return calculate();
}
);
std::future future =
task->get_future();
queue.push(
[task]() {
(*task)();
}
);
这样:
原始任务
↓
packaged_task
↓
包装成 void() callable
↓
统一进入 std::function 队列
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)
暂无评论