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 场景专门设计的)。

工程上有两条必须记住的:

  1. 取消是协作式的。一个跑纯计算的 then 里如果没有 poll token 的点,取消请求要等到它返回才生效。CPU 密集段必须自己分片检查,或者干脆把它挪到可取消的 scheduler 上。
  2. 第三方阻塞 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手工透传 tokentask<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 为边界做增量替换:

  1. 把现有的线程池/executor 包成一个 scheduler(只需提供 schedule() 返回的 sender),老代码照常跑;
  2. 用 starts_on / continues_on 显式声明线程归属——这比 Asio 的隐式 strand 更难写错,因为线程切换在类型里看得见;
  3. 优先在 I/O 边界与 pipeline 编排处落地 sender,这是收益最大、风险最低的位置;
  4. 纯 CPU 密集计算仍用普通循环,但记得分片 poll stop_token;
  5. 标准库尚未普及前,用 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 反例制导的抽象精化,内核是同一件事。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部