


💪 今日博客励志语录:
能力不是某一天突然拥有的,它只是无数个“我今天再搞懂一点”叠加出来的。
思维导图
单线程顺序执行
↓
某个函数执行时间很长
↓
后续逻辑并不依赖当前函数结果
↓
没有必要让当前执行流一直等待
↓
把耗时任务交给另一个执行流
↓
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




