CPU、GPU、NPU 这三类硬件,几乎每个做性能优化的人都绕不开。过去我写异构计算的时候,最常见的感受是:不是没能力把代码跑快,而是花了大把时间在“把代码从一个执行环境搬到另一个执行环境”上。CPU 有线程和任务库,GPU 有设备运行时,NPU 又有自己的一套推理接口,三套编程范式互不相认,最后只能靠一堆回调、条件变量和拷贝逻辑硬拼在一起。所以当看到 std::execution 这个提案进入 C++26 候选范围时,我最关心的不是“多了一个新语法”,而是它能不能真正把 CPU、GPU、NPU 的并行编程模型统一起来,让流水线逻辑可以用一套抽象写清楚。
这篇内容主要面向两类读者:一类是正在做异构计算、AI 推理、图像处理相关项目的 C++ 开发者,另一类是还没接触过 sender/receiver 模型、但想提前评估要不要在项目里押注 C++26 新特性的工程师。我会从为什么需要它出发,拆解核心抽象,再给一个我实际迁移项目时的踩坑记录,最后整理成问题排查表,尽量让你看完就能判断“这个提案到底能不能解决我手上的痛点”。
1. 三条硬件路线和一个统一梦想
1.1 现在的并行编程,活像三套方言互相翻译
先说说现状为什么难。CPU 这边,大家习惯的是 std::thread、线程池、OpenMP 或者基于任务的异步库,核心逻辑是一段段普通函数,调度单位是线程或者任务,同步靠 future、mutex、条件变量。GPU 这边,常见的写法是把“核函数”提交到设备队列,数据要显式或者通过统一内存机制传到设备端,执行模型更像“批量提交 + 批量等待”。NPU 更特殊,它往往面向推理场景,有自己的一套运行时,输入输出经常被封装成张量或缓冲区对象,执行流程更像“创建会话、设置输入、触发推理、读取输出”。
这三套方言导致同一个业务逻辑要写三遍。我参与过一个视频处理 Demo 的重构,原工程里 CPU 负责解码和预处理,GPU 负责图像缩放和特征提取,NPU 负责推理分类。旧代码为了把这三级串起来,用了一个共享内存队列加一组工作线程,每个线程循环 poll 自己的任务队列,状态全靠手写状态机。功能倒是能跑,但一旦要加一个新处理分支,就得改队列结构、加线程、加锁,改一次出一次问题。最要命的还不是代码量,而是延迟抖动——某个设备回调晚了几毫秒,后面所有依赖它的任务全都在空转。
这种局面不是因为某个开发者懒,而是语言标准没有提供一个跨后端的“依赖表达工具”。你可以在每个硬件上用它的原生接口写出高性能代码,但“谁先跑、谁等谁、结果怎么交接”这件事,缺少一个统一的抽象层。
1.2 为什么现有异步库也补不上这个缺口
有人可能会说,我用 Boost.Asio、libdispatch 或者各种异步框架,不也能做依赖编排吗?确实能,但大部分异步库的核心是“在一个执行上下文里调度任务”,对异构设备的支持往往只是封装了某个后端的运行时,并没有从模型层面解决“sender 在 CPU 上产生,却要在 GPU 上消费”的语义。
举一个我早期踩过的例子。当时想用 std::async 模拟异构流水线:CPU 算出一个中间结果后,需要交给 GPU 去处理。我第一反应是让 CPU 的计算函数返回一个 future,然后在 GPU worker 里 get() 这个 future。这看起来没问题,但实际上未来对象成为 CPU 任务和 GPU 任务之间的紧耦合桥梁,GPU worker 一旦被一个没完成的 future 阻塞,整个设备队列就被卡住了。后来换成轮询标志位,CPU 负载倒是降了,但延迟变得不可控,本质上还是在“阻塞”和“空转”之间二选一。
std::execution 想解决的核心就是这个问题:它把任务之间的依赖描述成一张有向无环图,让调度器来决定节点在哪个硬件后端上运行,而不是由业务代码里某一线程偷偷阻塞等待。这一点看起来简单,实际做起来需要一整套类型层面的抽象,于是就有了 sender、scheduler、receiver 这三兄弟。
2. std::execution 给 C++26 带来了什么核心抽象
2.1 Scheduler、Sender、Receiver 到底在说啥
很多教程喜欢一上来就贴概念代码,但我们先从场景理解。想象你开了一家餐厅,有多个档口:冷菜台、热菜台、烤箱。你要做一道菜,需要先让冷菜台准备配菜,再把配菜送到热菜台翻炒,最后送到烤箱烤制。在 std::execution 的模型里:
- Scheduler(调度器)就是“某套设备上的工作队列”,比如 CPU 线程池调度器、GPU 调度器、NPU 调度器。你告诉它“我要在你这儿干活”,它会给你一个执行任务的通道。
- Sender(发送者)是一个“工作的描述”,它像菜谱上的半成品,还没有真正下锅。它不执行,只描述“我要在某处运行某个函数,并产生某个结果”。Sender 可以组合,就像你可以先把配菜步骤写在纸面上,再决定把它们拼成完整流程。
- Receiver(接收者)是“结果送达到哪里去”。它规定了当发送者的工作成功完成、抛出异常或者被取消时,分别应该调用哪个回调。
这个模型和传统的“发起异步调用”最不一样的地方,是把“描述”和“执行”拆开了。你可以先构建一个 sender 图,完全不触发任何任务,然后等到条件成熟再启动。这样调度器就有机会看到整张图的依赖关系,把不同硬件节点合理排布到相应的设备队列里,而不用像旧代码那样一个任务一个任务地硬调。
2.2 connect 和 start:从图纸到点火的临门一脚
用代码来体会一下。下面是一个最小示例,用 std::execution 的 CPU 线程池跑一个加法:
#include <execution> #include <iostream> #include <thread> namespace ex = std::execution; int main() { auto pool = ex::static_thread_pool{4}; auto sched = pool.get_scheduler(); auto task = ex::schedule(sched) | ex::then([] { return 42; }) | ex::then([](int v) { std::cout << "result: " << v << '\n'; }); ex::sync_wait(task); }看起来像连续加了两个任务,其实背后的流程是:
ex::schedule(sched)创建了一个“在 sched 上执行”的 sender。|符号其实是把多个 sender 组合起来,组成一个新的 sender。ex::sync_wait(task)是这个库里为了方便测试而提供的辅助函数,它把 sender 和当前线程连接起来,启动执行,然后阻塞当前线程等结果。
在真正的内核里,connect 是把一个 sender 和一个 receiver 绑定,形成一个“运行状态”的操作;start 则是点火,让数据开始流动。为什么要分两步?因为你可以提前把图构造好,然后让调度器一次性掌握整张图的资源需求和依赖边界。如果像老式回调那样每步都立刻启动,你就失去了让整条流水线做全局调度的机会。
2.3 then、when_all、transfer、bulk 这四个组合操作
组合操作是 sender 模型的灵魂。我挑四个最常用的列一下:
| 操作 | 作用 | 类比 |
|---|---|---|
then | 在 sender 结束后执行下一个函数 | 流水线上的下一道工序 |
transfer | 把 sender 的执行上下文切换到另一个 scheduler | 把半成品从冷菜台送到热菜台 |
when_all | 等待多个 sender 全部完成,合并结果 | 等两道菜都做好再一起上桌 |
bulk | 对一段数据并行执行同一操作 | 让一百个人同时切一百个土豆 |
这里最值得留意的是transfer。它是连接 CPU、GPU、NPU 的关键点:当你想让“数据从 CPU 算完交给 GPU 继续算”,就用transfer(gpu_scheduler)把那个 sender 的后续工作换到 GPU 队列去执行,而整个图仍然保持一种可组合的形态。硬件毕竟是硬件,底层还是会有设备提交、拷贝这些操作,但业务代码不需要再手动管理跨设备的等待和同步了。
bulk则更贴近底层并行计算。GPU 上的大规模并行,本质上是一个二维执行网格;NPU 上的矩阵运算,也往往是数据并行。bulk接受一个 sender 和一段维度,可以把你熟悉的“在设备上并行跑 N 次”的语义封装在 sender 里,比用then去模拟更贴合硬件特性,也更容易做后端优化。
3. 统一 CPU、GPU、NPU 的关键路径
3.1 用 sender 表达式描述一条真实异构流水线
说了这么多抽象,我们来看一个接近实战的代码。假设现在有一个 AI 推理流水线:先用 CPU 解析图片并做预处理,再把张量交给 GPU 做特征计算,最后送到 NPU 跑推理模型,结果回到内存供上层调用。我用 sender 组合来写,看起来就像这样:
using namespace std::execution; scheduler auto cpu_sched = /* CPU 线程池调度器 */; scheduler auto gpu_sched = /* GPU 设备调度器 */; scheduler auto npu_sched = /* NPU 调度器 */; sender auto pipeline = just(parse_input(my_image)) // 先产生一个图片数据 | then(&preprocess) // 在 CPU 上预处理 | transfer(gpu_sched) // 切换到 GPU 队列 | then(&gpu_feature_extract) // GPU 上做特征提取 | transfer(npu_sched) // 再切换到 NPU | then(&npu_inference) // NPU 上推理 | transfer(cpu_sched) // 回 CPU | then(&collect_result); // 收集结果到 CPU 内存 auto final_result = sync_wait(pipeline).value();这行代码背后的执行语义:CPU 上的preprocess跑完后,数据不经过任何等待变量就直接发给 GPU 调度器;GPU 计算完成后立即调度到 NPU。每个transfer都意味着一次执行环境的切换,但这一切是由 sender 图自动编排的,不需要我在业务代码里写 mutex、condition_variable 或者设备同步 API。
不过要冷静看待的是,这并不意味底层拷贝会消失。CPU 内存到 GPU 显存、GPU 显存到 NPU 内存,物理上的数据移动逃不掉。这个模型带来的价值是让“何时移动、移动结果交给谁”变得可组合,而不是让开发者在每个环节都手工跟踪缓冲区的状态。
3.2 异构内存和生命周期是绕不开的大坑
统一抽象里最容易翻车的地方,不是调度器,而是内存。CPU 用普通堆内存,GPU 用的是设备端显存或统一内存,NPU 则可能要求张量对象驻留在自己的专用内存池里。如果一个 sender 捕获了一个 GPU 指针,然后被transfer到 NPU 调度器,底层后端无法自动帮你完成指针转换,除非设备运行时本来就有统一地址空间的支持。
我个人的经验是把所有跨设备传递的“数据”包装成共享句柄,句柄内部记录“当前数据在哪个设备上、是否已同步、释放谁负责”。在 sender 里传递的不是裸指针,而是这个共享句柄。这样then的函数在哪个设备上跑,就知道该访问哪个映射版本。用标准库说法,就是让捕获资源具备 RAII 语义,同时让拷贝语义尽量轻量。
生命周期上最容易犯的一个错误是:拿着一个局部对象的引用放进then的 lambda 里,然后立刻sync_wait。如果这个 lambda 在另一个设备的队列里延迟执行,局部对象已经析构,程序就会崩溃或者跑出完全错误的结果。正确做法是让 lambda 按值捕获,并且用智能指针或共享句柄管理长生命周期的资源。
3.3 硬件后端到底怎么适配进标准模型
很多人误以为 C++26 一旦加入 std::execution,CPU、GPU、NPU 的运行时就会自动统一。实际上,标准只提供一张“用户界面和依赖图语义”的蓝图,真正让某块硬件跑起来,需要由该硬件的厂商或者第三方库去实现一套 scheduler 后端。
一个后端要接入 sender/receiver 生态,通常要做三件事:
- 提供符合 scheduler 概念的对象,它能返回代表“在此设备上执行”的 sender。
- 实现 connect 流程:把 sender 与 receiver 绑定,并准备好设备端的回调状态。
- 在 start 时把任务真正提交到硬件队列,并在完成后触发 receiver 的 set_value、set_error 或者 set_stopped。
对于只想使用功能的应用开发者来说,不需要关心每个后端内部怎么写;但对于想给某 NPU 赋能的人来说,建议先做一个基于 CPU 的仿真 scheduler,验证整套 sender 图的 connect/start 流程通顺,再接入真实设备运行时。我见过不少团队直接上手写设备队列,结果发现编译期概念没过,整个 sender 图根本拼不起来。先把“能连通、会调用、能传值”这三关过了,再去追求性能。
4. 迁移实践中的经验与血泪
4.1 从一个“线程+共享队列”Demo 迁移到 sender/receiver
我在自己的某个模拟项目里,曾经把一个老旧的多阶段计算流水线从手写线程队列改成 sender/receiver 模型。严格说那只是为了踩坑用的实验工程,但收获非常大。
旧代码是一个类似流水线的结构:五个处理节点,每个节点有独立线程,节点间通过队列传递对象。节点 A 的线程往队列里塞数据,节点 B 的线程阻塞等待队列。为了不丢消息,我不得不把队列操作放到一个全局锁里。整个系统里那几把锁和条件变量,成了后续所有崩溃和卡顿的根源。
迁移时我做的事很简单,把每个节点的处理函数改成then的调用,然后按依赖顺序用|连接起来。原来需要手工维护的“节点是否就绪、队列是否满、结果是否已取”这些状态,全部由 sender 图的连接逻辑替代。最直观的变化是代码量少了大概一半,没有了条件变量,也不再需要到处检查空队列。
当然,代价是思维模式要变。团队成员如果没接触过“描述式”编程,一开始会很不习惯这种“写了一段代码但是它不执行”的节奏。我在代码评审时也反复被问“这个 then 到底是何时跑的”。最好是先搭一个最小的例子,让大家亲手看到schedule和then的组合并不会立即执行,只有sync_wait或者submit才会触发,这样一切就都好理解了。
4.2 调试和性能分析有哪些实用土办法
调试异步图最头疼的问题是:出错时没有完整调用栈。我在排查一个 GPU 后端 bug 时,发现错误被 sender 里的 receiver 机制包装了一层又一层,最后只能在每个关键then分支插日志,看数据到底走到哪一步断掉。
有几个可操作的建议:
- 在关键节点插入
then的日志函数,输出当前阶段名、线程 id、相关数据大小,形成一条时间线。 - 用
sync_wait把整条流水线阻塞到末尾,可以避免程序提前退出导致回调丢失。 - 使用
upon_error把异常信息打印出来,而不是让异常静默消灭在异步图里。 - 在性能分析时,先关掉所有日志,再用系统级的 profiler 看设备队列的占用率,重点看有没有设备长期空闲的阶段。
另外我有一个个人习惯:每个跨设备transfer前后都加上一个“当前设备标识”的日志,方便我在最终日志时间线里确认数据是否按预期切换了后端。否则一旦某个调度器实现不标准,数据可能在 CPU 上偷偷算完,性能瓶颈误判到 GPU 代码上。
4.3 不要为了用新特性而强行统一
转移到 sender/receiver 范式后,我并没有把所有并行代码都重写一遍。项目里有一些纯粹的单机多线程任务,用 std::thread 线程池原样跑,效率也很高,没必要强行包装成 sender 图。统一模型真正的价值在于“跨后端依赖复杂”的场景;如果只是某个循环并行化,直接用现有库或者 openmp 可能更简单。
我建议的迁移顺序是:先选一条真实存在的异构链路,例如 CPU 预处理 + GPU 算子,把它改造成 sender 图,跑通后再加 NPU 分支。不要在第一天就试图把 CPU、GPU、NPU 三端全部接入,那会同时引入概念理解成本和多个后端适配风险,问题一旦出现,根本分不清是后端 bug 还是模型误用。
5. 常见问题排查速查表
5.1 看着像死锁,其实只是谁都不会主动交棒
| 症状 | 可能原因 | 排查与解决 |
|---|---|---|
| 程序启动后卡住不动 | sender 图没有被 start,或sync_wait等到的 sender 不在主链上 | 检查是否所有 sender 都被连接到一个最终接受结果的 receiver 上;确认sync_wait接收的是整条图的根 sender |
| 设备任务一个接一个地串行执行 | 在 GPU/NPU 上用了大量then,没有用bulk或没有体现并行维度 | 把数据并行部分改成bulk,并查看调度器的并发配置 |
| 显存占用持续增长 | sender 捕获了持有设备资源的对象,并且拷贝了多份 | 把设备资源包装成共享所有权句柄,确保析构时只释放一次 |
| 出现野指针或崩溃 | then的 lambda 按引用捕获了局部变量 | 一律按值捕获,对象生命周期至少要覆盖根 sender 的执行期 |
| 编译错误集中在概念名上 | 某个自定义 scheduler 没有完整实现概念要求 | 先回退到 CPU scheduler,确认图逻辑正确,再逐项对照后端实现 |
表里最常出现的还是最后两类。我看过太多新手把 sender 里的 lambda 写成[&],然后在一个函数里构建 sender、同步等待,结果函数一退出,引用对象就没了。记住一句话:sender 是“描述”,描述里引用的东西必须在你真正点火之间一直活着。
5.2 当错误和取消同时发生时,别把异常搞丢
异步编程里的取消是一个微妙话题。想象一条发送者图已经跑了一半,某个节点失败,此时后续节点应该收到错误信号;与此同时,某个分支被外部取消,正在执行的任务应该能响应停止。在 std::execution 的模型里,receiver 分别有 set_value、set_error、set_stopped 三条通道,目的就是为了区分“成功完成”“失败完成”和“被取消”。
实际项目中我踩过的问题是:有一个when_all分支里的设备内核失败抛错,但另一个分支还在跑,最后整张图陷入等待。这是因为我没有给失败分支设置正确的错误传播链,导致错误没有第一时间取消其它分支。排查这类问题的思路是:在每个可能失败的后端 sender 上挂upon_error,把错误信息变成正常的 sender 值传递,同时让when_all在遇到错误时能响应停止信号,及时把其它分支取消掉。
6. 我和这个模型相处一段时间后的个人体会
最后的分享不是什么结论,只是一个实践者的直觉。
如果只看表面上那些then和transfer,会觉得这就是换了一个异步写法;真正用到项目里,会发现它最大的价值是强迫你把“每个任务在哪个设备上跑、它的依赖是谁、它失败之后该怎么办”这些原本隐式的问题变成一套显式声明。以前是写代码的人靠脑内模拟整条流水线,现在是把流水线画成类型和调用关系给编译器看。这个过程本身就会逼你把设计想清楚。
我的建议是,现在就可以花一个周末,用 CPU 线程池模拟 GPU 调度器,再把一个真实项目中的单条链路搬过来,不用等 C++26 正式发布。几个主流编译器和库已经提供了实验性实现,语法细节可能有出入,但 sender/receiver 的核心思路已经稳定。等你真正写过一个完整的 sender 图,再看标准文档里那些调度器和发送者的概念名,就不会觉得头大了。
一个人从头到尾维护过多硬件流水线之后,最大的奢侈不是代码写得花哨,而是当硬件换了一代、驱动接口变了之后,业务逻辑的骨架还能保留下来。std::execution 这套模型朝这个方向迈了一大步,至于它最终能把 C++ 的异构编程带多远,还得看在真实项目里打磨多久。