
CPU、GPU、NPU 这三类硬件几乎每个做性能优化的人都绕不开。过去我写异构计算的时候最常见的感受是不是没能力把代码跑快而是花了大把时间在“把代码从一个执行环境搬到另一个执行环境”上。CPU 有线程和任务库GPU 有设备运行时NPU 又有自己的一套推理接口三套编程范式互不相认最后只能靠一堆回调、条件变量和拷贝逻辑硬拼在一起。所以当看到 std::execution 这个提案进入 C26 候选范围时我最关心的不是“多了一个新语法”而是它能不能真正把 CPU、GPU、NPU 的并行编程模型统一起来让流水线逻辑可以用一套抽象写清楚。这篇内容主要面向两类读者一类是正在做异构计算、AI 推理、图像处理相关项目的 C 开发者另一类是还没接触过 sender/receiver 模型、但想提前评估要不要在项目里押注 C26 新特性的工程师。我会从为什么需要它出发拆解核心抽象再给一个我实际迁移项目时的踩坑记录最后整理成问题排查表尽量让你看完就能判断“这个提案到底能不能解决我手上的痛点”。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 给 C26 带来了什么核心抽象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 硬件后端到底怎么适配进标准模型很多人误以为 C26 一旦加入 std::executionCPU、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 调度器再把一个真实项目中的单条链路搬过来不用等 C26 正式发布。几个主流编译器和库已经提供了实验性实现语法细节可能有出入但 sender/receiver 的核心思路已经稳定。等你真正写过一个完整的 sender 图再看标准文档里那些调度器和发送者的概念名就不会觉得头大了。一个人从头到尾维护过多硬件流水线之后最大的奢侈不是代码写得花哨而是当硬件换了一代、驱动接口变了之后业务逻辑的骨架还能保留下来。std::execution 这套模型朝这个方向迈了一大步至于它最终能把 C 的异构编程带多远还得看在真实项目里打磨多久。