C++26 std::execution 深度工程实战:从 Sender/Receiver 代数、取消传播到生产级调度器的全链路解析
执行摘要:C++26 把 P2300 std::execution 正式并入标准库,这是继 C++11 std::async 之后 C++ 异步模型最重大的一次重构。它给出的不是又一套语法糖,而是一个可组合、惰性求值、把取消当作一等公民的异步代数:Sender 描述工作、Receiver 接收结果、connect + start 驱动执行。本文拆开这套代数的四个工程要点——三通道完成模型、结构化并发作用域、协作式取消、以及零堆分配的 operation state——并给出一个可落地的 io_uring sender 实现骨架,最后讨论从既有回调/协程代码迁移的现实路径。
一、三十年异步债务:三条岔路都不通
C++ 的异步史上有三条主干,每一条都在生产里留下过坑。
std::future:std::async 返回的 future 只能 .get() 阻塞等待,没有标准 continuation(.then 提案从未进标准),没有取消语义,多个 future 的并发行只能靠 when_any 的提案草案手写。一旦你要"等 A 和 B 都完成再算 C",标准库就交白卷了。
回调嵌套:这是 C++ 生产代码里最常见的形态。问题不在语法,而在生命周期与取消——this 捕获要不要 shared_from_this?超时了谁负责把链路上所有 pending 操作标记失效?答案通常是每人各写一套布尔标志和状态机,于是每家公司都有一个不一样的"半吊子异步框架"。
C++20 协程:co_await 把异步写成顺序,可读性是一次巨大进步。但它解决的是"怎么写得像同步",而不是"怎么组合与取消":
- 协程帧通常需要堆分配,HALO 优化只在编译器能证明生命周期不外逃时生效,一旦跨函数返回
task<T>就退化; - 结构化并发不在语言里,
co_await两个并发分支要自己搭when_all; - 取消要手动把
stop_token一路透传,漏一层就失效; task<T>这类类型擦除带来间接调用与分配开销。
三者的共同缺失是:没有一个"可组合的异步值"抽象。Sender/Receiver 补的正是这一层。
二、Sender/Receiver 的代数:三个角色,一个契约
模型只有三个角色:
- Sender:描述一段工作,惰性的。不启动它,什么都不会发生——这是与 future/协程最关键的区别,future 一创建往往就已经在跑了。
- Receiver:结果的消费者,通过三个通道接收完成通知。
- Operation state:
connect(sender, receiver)的产物,调用start(op)才真正启动。
using namespace std::execution;
auto sndr =
schedule(pool.get_scheduler())
| then([] { return load_config(); })
| let_value([](Config cfg) {
return when_all(
fetch_user(cfg) | continues_on(pool.get_scheduler()),
fetch_order(cfg) | continues_on(pool.get_scheduler())
);
})
| then([](User u, Order o) { return render(u, o); });
// 直到这里才执行;sync_wait 阻塞到完成
auto [html] = this_thread::sync_wait(std::move(sndr)).value();
三个完成通道是整套设计的核心:
| 通道 | 语义 | 触发时机 |
|---|---|---|
set_value | 成功,携带 0..N 个值 | 正常完成 |
set_error | 失败,携带异常/错误码 | 业务或系统错误 |
set_stopped | 被取消,无值 | 上游请求停止 |
把取消从错误里独立出来,这是 Sender/Receiver 相对 future 与大多数回调框架最本质的进步。在 future 模型里,取消只能伪装成异常(future_error / broken_promise),调用方不得不用 try-catch 区分"真错了"和"我不想要了"。有了 set_stopped,取消是一条平行的、类型可见的通路,编译器能强制你处理它。
另一个工程红利是 completion_signatures:每个 sender 在编译期就知道自己会发什么。
using S = completion_signatures_of_t<decltype(sndr)>;
// set_value_t(std::string), set_error_t(std::exception_ptr), set_stopped_t()
这意味着适配层可以在编译期做穷尽性检查——你不可能写出一个"忘了处理取消"的 receiver。
三、结构化并发:作用域就是生命周期
when_all 是 join,let_value 是嵌套作用域。二者的组合天然表达"子任务的生命周期不得超出父作用域":
auto pipeline =
on(io_sched, read_request())
| let_value([](Request r) {
// r 的生命周期覆盖整个 when_all
return when_all(
query_db(r.id),
query_cache(r.id)
) | then([&](...) { return merge(...); });
})
| upon_error([](std::exception_ptr e) { return fallback(); });
对比 Go 的 errgroup 或 Python 的 TaskGroup:思路一致,但 C++ 版本把它放进了类型系统而不是运行时约定。父作用域被取消时,停止信号沿 receiver 的 environment 自动向下广播,子任务不需要显式注册。
需要提醒的是:P3149 async_scope(动态 spawn 的作用域)没有进入 C++26。如果你需要"运行时才确定数量的并发任务",目前要么用 NVIDIA stdexec 提供的 async_scope / nest(),要么自己用引用计数实现一个 spawn gate。不要假设标准库里有它。
四、取消是数据,不是异常
取消通过 receiver 的 environment 传播:
auto tok = get_stop_token(get_env(rcv));
if (tok.stop_requested()) { set_stopped(std::move(rcv)); return; }
注册回调使用 std::stop_callback(C++26 起可用 inplace_stop_token,无堆分配、无原子递增,这是为 sender 场景专门设计的)。
工程上有两条必须记住的:
- 取消是协作式的。一个跑纯计算的
then里如果没有 poll token 的点,取消请求要等到它返回才生效。CPU 密集段必须自己分片检查,或者干脆把它挪到可取消的 scheduler 上。 - 第三方阻塞 API 是不可取消的。
getaddrinfo、mysql_real_query这类同步调用一旦进去就出不来,唯一的解法是换异步接口(io_uring / c-ares)或把整段扔到可丢弃的线程上——后者意味着资源泄漏风险。
一个常见反模式:把"超时"实现成 set_error(timeout_error)。正确做法是让它走到 set_stopped,否则上层无法区分"服务挂了"和"我等不及了"——这两者的重试、降级、告警策略完全不同。
五、写一个真正能用的 sender:io_uring 读操作
标准库自带的 sender 很有限,真正的价值在于你可以低成本地写自己的。下面是一个 io_uring 异步读的骨架:
struct read_sender {
using sender_concept = std::execution::sender_t;
using completion_signatures = std::execution::completion_signatures<
std::execution::set_value_t(ssize_t),
std::execution::set_error_t(std::error_code),
std::execution::set_stopped_t()>;
uring_ctx* ctx; int fd; void* buf; size_t len; off_t off;
template <class Receiver>
struct op {
uring_ctx* ctx; int fd; void* buf; size_t len; off_t off;
Receiver rcv;
std::optional<std::inplace_stop_callback<on_cancel>> cb;
void start() noexcept {
auto tok = std::execution::get_stop_token(
std::execution::get_env(rcv));
if (tok.stop_requested()) { // 提交前就已取消
std::execution::set_stopped(std::move(rcv));
return;
}
cb.emplace(tok, on_cancel{ctx, this}); // 注册取消回调
auto* sqe = ctx->get_sqe();
io_uring_prep_read(sqe, fd, buf, len, off);
io_uring_sqe_set_data(sqe, this); // user_data 指回 op
ctx->submit();
}
void complete(ssize_t res) noexcept {
cb.reset(); // 先解绑取消回调
if (res < 0)
std::execution::set_error(std::move(rcv),
std::error_code(-res, std::system_category()));
else
std::execution::set_value(std::move(rcv), res);
}
};
template <class Receiver>
auto connect(Receiver&& r) {
return op<std::remove_cvref_t<Receiver>>{
ctx, fd, buf, len, off, std::forward<Receiver>(r)};
}
};
取消回调里提交一个 IORING_OP_ASYNC_CANCEL(带 IORING_ASYNC_CANCEL_FD),内核会尽力撤销已提交的 SQE;撤销成功走 set_stopped,失败则由原 CQE 正常完成并走 set_value——这两条路径都合法,receiver 必须都能处理。
注意 op 是 connect 的返回值,它活在调用者的栈帧上。整条管道的连接结果是一个嵌套的栈对象,没有一次 malloc。这就是 sender 在性能上压过协程帧的根本原因。
六、性能账:为什么 sender 可以更快
| 方案 | 状态存储 | 组合性 | 取消 | 类型擦除 |
|---|---|---|---|---|
std::future | 堆上 shared state | 无 | 无 | 是 |
| 回调链 | 用户自管 | 手工 | 手工 | 视实现 |
| C++20 协程 | 协程帧(常堆分配) | co_await | 手工透传 token | task<T> 常擦除 |
| Sender/Receiver | 栈上 op state | 管道运算符 | 内建 stopped 通道 | 可选,可全程静态 |
三点值得强调:
- 零堆分配:
op嵌套在栈上,编译器还能内联跨层调用。对比协程帧的堆分配,缓存局部性与分配器压力都更好。 - 无强制擦除:只要不主动用
any_sender/type_erase,整条链是静态类型,虚表与std::function都不存在。 - 代价是编译期:模板膨胀、编译时间上涨、错误信息极长(一个漏写的
set_stopped能刷出上百行诊断)。这是这套模型的真实成本,别指望零代价。
顺带一提:P2300 早期的 allocator 定制点(get_allocator)已被 P3175 从 C++26 中移除,理由正是"operation state 大多可以放在栈上,这个定制点的收益配不上复杂度"。需要动态分配的场景请自己管理。
七、迁移策略与陷阱清单
不要一次性重写。 现实可行的路径是以 scheduler 为边界做增量替换:
- 把现有的线程池/executor 包成一个
scheduler(只需提供schedule()返回的 sender),老代码照常跑; - 用
starts_on/continues_on显式声明线程归属——这比 Asio 的隐式strand更难写错,因为线程切换在类型里看得见; - 优先在 I/O 边界与 pipeline 编排处落地 sender,这是收益最大、风险最低的位置;
- 纯 CPU 密集计算仍用普通循环,但记得分片 poll
stop_token; - 标准库尚未普及前,用 NVIDIA
stdexec做过渡,它的接口与 C++26 高度一致,迁移成本很小。
与 Asio 互操作:Asio 侧可用 asio::experimental::deferred 与 as_tuple 构造可组合的异步操作;反之要把 Asio 的 completion handler 包成 sender,用 async_initiate 桥接即可。两者不是非此即彼。
陷阱清单:
- 不要在调度线程里调用
sync_wait——必然死锁; - sender 默认只能启动一次,需要多次启动请显式
split或ensure_started; - 不要在
then里做阻塞调用,你会占死调度线程,且取消无法生效; - 不要把取消塞进
set_error,两者语义与运维策略完全不同; connect只是"准备好",不等于"已经在跑"——惰性求值既是最强特性,也最容易让人误判时序。
八、结论
std::execution 真正的贡献不是让异步代码更好看,协程已经做到了这一点;它做的是把组合、取消、执行上下文这三件事从"每个项目各自发明的约定"沉淀成标准库里的代数结构。
一个值得带走的设计范式:当一类失败是结构化的、可归因的,就把它编码成约束,而不是换条路重走。 取消变成独立的 set_stopped 通道、作用域变成 let_value 嵌套、执行上下文变成显式的 scheduler——每一次都是"用结构换确定性"。这个思路并不局限于 C++:从分布式系统里把冲突消解编码进 CRDT,到编译器把失败归因编码进 CEGAR 反例制导的抽象精化,内核是同一件事。

发表评论 取消回复