首页 / 资讯中心 / 文章详情

C++20协程实现原理与实战:从机制到调度器设计

C++20协程实现原理与实战:从机制到调度器设计 ★ FEATURED ARTICLE
聊到 C20 新特性解析最值得写又最容易劝退的就是协程库的实现原理。它和你以前用过的任何 C 库都不太一样——它不是给你一个现成的Task、Future拿来就用而是给了你一堆“半成品”的抽象让你自己去拼装成真正能跑的东西。我第一次照着示例写完一个能跑的协程时心里其实特别发怵它确实能跑但我完全说不清它为什么能跑。这篇文章我想把 C20 协程从底层机制到实际应用完整拆一遍重点回答三个问题它和线程比到底省了什么编译器在背后替我们做了什么真正落地时哪些坑最常见适合刚接触协程的 C 开发者也适合那些已经写过几版协程封装但总感觉哪里不对的人。不少人接触协程后第一个困惑是这东西我怎么越学越复杂别急着怀疑自己C20 的协程设计确实和 Python、JavaScript 里的 async/await 很不一样。标准委员会把“机制”和“策略”彻底分开了——语言只规定了编译器怎么把协程函数变成可暂停可恢复的状态机至于怎么调度、怎么管理生命周期、怎么和 I/O 事件循环结合全部留给你自己实现。所以你会发现标准库里连一个最基本的task类都没有只有std::coroutine_handle和std::suspend_always这种底层工具。这篇文章我会从这三个底层工具讲起手写一个最小可用的协程任务类再做一层调度器最后把调试协程时最容易让人抓狂的几个问题都摆出来。1. 为什么 C20 协程和其他语言的协程不一样——先搞清楚它在解决什么问题很多人在学 C20 协程之前已经用过 Python 的async/await或者 C# 的async他们期待的是同样顺滑的体验写起来像同步代码跑起来是异步的底层细节全部被隐藏。但 C20 给的是另一种东西所以第一个要掰清楚的概念就是C20 协程到底想解决什么问题。1.1 协程和线程、回调的本质区别先看线程模型。一个线程在等待 I/O 时哪怕是等待几毫秒的磁盘读写整个线程都被阻塞住不能做任何其他事情。为了扛住上万并发连接传统方案就是开线程池但线程的创建、切换和销毁都有开销尤其是线程上下文切换时会涉及内核态和用户态的双重切换在线程数量上去之后这开销会反噬整个系统的吞吐量。回调模型解决了线程阻塞的问题你发起一个异步 I/O 操作注册一个回调函数等 I/O 完成后事件循环来调用它。但回调带来的问题是控制流倒转逻辑被拆得七零八落。一个简单的“读数据—处理—写数据”流程用回调写出来就是三层嵌套每加一步缩进就深一层异常处理和错误传播更是灾难。协程走的是另一条路用一个“可暂停的函数”来表达异步流程。函数执行到某一步发现数据还没准备好就主动让出控制权把线程让给别的协程去跑等数据准备好了再从这个暂停点继续执行。听起来很像线程切换但协程切换完全发生在用户态不需要内核介入通常只需要保存少量寄存器和恢复指针开销比线程切换小一个数量级以上。1.2 标准库只给机制不给工具的深层原因理解了协程要解决的问题下一个问题自然冒出来既然协程这么好为什么标准库不直接给你一个std::taskT让你写co_await read_some()就完事了原因是 C 的应用场景太宽泛了没有一个统一的调度模型能适配所有情况。游戏引擎里协程要考虑每帧的更新时间切片网络库要考虑和 epoll/IOCP 的事件循环结合嵌入式环境可能连动态分配都不允许。如果标准库强推一种task就会把大量场景排除在外。所以委员会选择了把“协程机制”放到语言层把“协程策略”完全开放你可以在自己的库里定义promise_type、awaiter按自己的调度方式来控制协程的暂停、恢复和销毁。这意味着什么意味着 C20 协程的学习曲线比其他语言陡峭但换来的掌控力也是前所未有的。你写的每个co_await背后具体怎么挂起、怎么恢复都是你可以干预的。我见过有人抱怨这设计太底层但我更愿意把它理解为C 给你的不是一把开好的锁而是一套锁芯的图纸。2. 编译器在协程背后替你做的事状态机与三个基石前面说了协程机制接着聊实现原理。C20 协程能做到“暂停/恢复”这件事靠的并不是运行时库而是编译器把你写的函数体改造成一个状态机。理解这个状态机后面所有代码看起来就顺了。2.1 编译器如何把函数体变成可暂停的状态机一个普通函数执行过程是一条线栈帧随调用结束自动销毁。协程函数不一样它在中间可能暂停可能一停就是很久恢复之后还要继续往下跑。所以编译器不能在栈上分配协程的局部变量——因为协程帧的寿命和函数调用栈不匹配它可能会活到外层函数早已返回之后。编译器实际做的是为每个协程调用分配一个“协程帧”coroutine frame把函数体里的所有局部变量、当前暂停点的位置、promise 对象、参数拷贝都放到这个帧里。函数体内每出现一个co_await或co_return就相当于状态机里的一个状态。函数一开始执行到co_await时保存现场并返回一个“未完成”的标志给调用者之后某个时刻外部通过coroutine_handle调用resume()编译器生成的代码就从暂停点继续往后执行。这个设计代价是每次协程调用通常要发生一次堆分配协程帧默认由operator new分配。这是 C20 协程被诟病最多的点之一后面讲调度器时我专门讲怎么规避。2.2 promise_type、awaiter、coroutine_handle 各自承担什么角色编译器生成了状态机但状态机怎么和用户代码沟通C20 引入了三个关键抽象任何一个协程类型都必须配合它们工作。promise_type定义在返回对象内部的一个类型。它是协程和调用者之间的“通信信道”。协程可以通过它把结果交出去调用者可以通过它拿到返回值或异常还可以通过initial_suspend/final_suspend决定协程开始和结束时的行为。awaiter一个定义了await_ready、await_suspend、await_resume三个方法的类型。co_await表达式的行为完全由这三个方法决定。标准库提供了两个现成的std::suspend_always表示无条件挂起std::suspend_never表示绝不挂起。coroutine_handle协程的句柄有点像一个智能指针。外部代码通过它来resume()一个已挂起的协程或者destroy()一个不再需要的协程帧。用一个不太严谨但很好记的类比promise_type是协程的“档案柜”awaiter是协程的“红绿灯”coroutine_handle是协程的“遥控器”。三者协作才能让状态机真正运转起来。2.3 一次完整协程调用的握手顺序从头到尾理一遍执行顺序你会发现每一步停在哪、继续跑在哪都是可以控制的。以Task task() { co_await something; co_return; }为例调用者调用task()。编译器分配协程帧在帧内构造promise_type。调用promise.get_return_object()把返回对象比如我们的Task类交给调用者。调用promise.initial_suspend()并co_await它的结果。如果返回suspend_always{}协程立即暂停函数返回控制权交回调用者——这就是“惰性启动”的实现方式。调用者持有Task对象在合适的时机调用task_handle.resume()协程进入函数体执行。执行到co_await something先评估something表达式的await_ready()。如果返回false就调用await_suspend(handle)把当前协程挂起控制权返回给resume()的调用方。某个时刻外部或另一个协程恢复这个协程await_resume()的返回值作为整个co_await表达式的值协程继续往下执行。最终执行到co_return调用promise.return_value(...)或return_void()然后执行final_suspend()。final_suspend挂起后协程帧不会被自动销毁谁持有句柄谁来负责调用destroy()。如果final_suspend返回suspend_never{}则协程帧在函数结束时会自动释放。这个顺序里最隐蔽的是第 8 和第 9 步很多人以为协程执行完就自动释放了其实不一定。这是个非常大的内存泄漏源后面踩坑部分专门讲。3. 手写一个最小可用的 task 协程类从声明到能跑通的全过程原理讲再多不如自己写一遍。这节我用最精简的代码实现一个TaskT它能被co_await能返回值也能抛异常。为了让代码不过度膨胀我做了适度简化但核心机制全部保留。3.1 骨架设计Promise、Task、Awaiter 三层如何黏合TaskT对外看起来是“一个可以等待的任务”内部由三层构成TaskT::promise_type管理协程帧的中间状态保存返回值或异常。TaskT本身持有std::coroutine_handlepromise_type负责对外暴露接口并在析构时释放协程帧。TaskT::operator co_await对应的 Awaiter负责把外层协程的句柄保存到内层 promise然后在子协程完成后恢复外层。写代码之前先明确一个关键决策initial_suspend我用std::suspend_always{}让任务是“惰性启动”的。调用task()时函数体并不同步执行而是要等到有人co_await它才真正跑起来。这和std::async的立即执行模型不同但更适合做异步任务链——你可以在构造任务后给它注册完成回调再把它交给调度器去跑。3.2 第一步实现 Task 类和 promise_type先看骨架代码#include coroutine #include exception #include utility #include optional templatetypename T class Task { public: struct promise_type { std::optionalT result_; std::exception_ptr error_; std::coroutine_handle continuation_; Task get_return_object() { return Task{ std::coroutine_handlepromise_type::from_promise(*this) }; } std::suspend_always initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { if (continuation_) { continuation_.resume(); } return {}; } void return_value(T value) { result_.emplace(std::move(value)); } void unhandled_exception() { error_ std::current_exception(); } }; explicit Task(std::coroutine_handlepromise_type h) : coro_(h) {} Task(Task other) noexcept : coro_(std::exchange(other.coro_, nullptr)) {} Task operator(Task) noexcept delete; Task(const Task) delete; Task operator(const Task) delete; ~Task() { if (coro_) { coro_.destroy(); } } // 用于从外部同步等待结果 T get_result() { if (!coro_.done()) { coro_.resume(); } return std::move(*coro_.promise().result_); } private: std::coroutine_handlepromise_type coro_; };coro_是协程句柄Task的析构函数里调用coro_.destroy()确保协程帧最终被释放。这里有个细节容易忽略continuation_是挂在promise_type上的std::coroutine_handle它不是协程本身的句柄而是“等这个任务完成后要继续执行的那一个外层协程”。后面co_await会把外层协程句柄填进来。3.3 第二步让 Task 可以被 co_awaitTask现在能创建、能析构但还不能被co_await。要让一个类型支持co_await标准做法是给它提供operator co_await或者让它自身满足 awaiter 接口。我给Task写一个operator co_awaittemplatetypename T class Task { // ... 前面的代码 ... struct TaskAwaiter { Task task_; bool await_ready() noexcept { return false; } void await_suspend(std::coroutine_handle continuation) { task_.coro_.promise().continuation_ continuation; task_.coro_.resume(); } T await_resume() { auto p task_.coro_.promise(); if (p.error_) { std::rethrow_exception(p.error_); } return std::move(*p.result_); } }; TaskAwaiter operator co_await() { return TaskAwaiter{*this}; } // ... 析构函数 ... };当外层协程执行co_await task时await_ready()返回false表示挂起外层然后await_suspend被调用参数continuation就是外层协程的句柄。我们把外层句柄存进内层协程的promise.continuation_然后立刻resume()内层任务。等内层任务执行到final_suspend时会检查continuation_并恢复外层协程。一个很容易踩的坑藏在await_resume如果内层协程抛了异常unhandled_exception()会把异常指针存起来此时协程照常结束不会像普通函数那样向外传播。如果你不手动重新抛出异常会被吞掉。我在这里用std::rethrow_exception把它重新抛给外层协程这是任务链能正确传播错误的关键。3.4 第三步写个简单的协程函数验证跑通有了上面的类可以写一个最朴素的测试#include iostream Taskint inner() { co_return 42; } Taskint outer() { int v co_await inner(); co_return v 1; } int main() { auto t outer(); int result t.get_result(); std::cout result std::endl; // 应该输出 43 }外层outer()调用时因为initial_suspend函数体先不执行。主函数调用get_result()内部coro_.resume()让outer()开始执行执行到co_await inner()时外层挂起启动内层任务内层执行完co_return 42后final_suspend触发恢复外层外层拿到42加一得到43协程结束。跑通这个例子后你基本就掌握了 C20 协程的所有核心概念。剩下的调度器、对称转移、内存优化都是在这个基础上做文章。4. 从能跑到能扛设计调度器时必须考虑的四个问题手写的Task只能在单线程上做最简单的“谁等谁恢复”。现实中的异步系统要处理成千上万个并发任务这时候你会发现四个问题绕不开任务何时启动、如何控制栈增长、协程帧怎么分配、任务完成之后由谁清理。4.1 对称转移与非对称转移为什么嵌套 co_await 会撑爆栈如果你直接跑上面那个Task再嵌套两层co_await不会出问题。但如果你在final_suspend里不是“保存外层句柄返回挂起”而是直接递归调用continuation.resume()问题就来了。想象一个极端场景任务 Aco_await任务 BB 完成后恢复 A恢复完 B 自己还没退出还在栈上占据着 frameA 继续执行后又co_await任务 CC 完成后恢复 AC 的 frame 又叠在 B 的上面。每多一层嵌套调用栈就高一层。一万个任务链式恢复栈深度就有一万层栈溢出几乎是必然的。C20 协程标准没有强制要求“对称转移”但很多高性能协程库都会在final_suspend里把控制权直接交还给调度器而不是恢复一个外层协程。调度器维护一个就绪队列协程要恢复时不是“直接 resume”而是“把自己塞回就绪队列让顶层事件循环来调度”。这样无论多少层任务链调用栈深度都维持在一个很浅的水平。我在自己的项目里就是这么做的协程之间不直接互相 resume所有 cross-coroutine 恢复都经过一个直径很小的调度器。4.2 协程帧的内存分配默认行为与定制方法前面提过编译器默认会给每个协程帧分配堆内存。这是协程比普通函数慢的根本原因之一——高并发场景下每秒可能创建数百万个协程每次都走通用operator new会有不小的开销和碎片。优化思路有几个层次如果协程帧小于某个阈值很多调度器会用一个“内存池”来复用协程帧避免反复分配释放。编译器允许给 promise_type 定义重载的operator new让协程帧的内存来源可控。因为编译器生成代码在需要分配帧时会优先调用promise_type::operator new。如果整个协程帧能放进调用者的栈空间即知道协程不会比调用者活得更久某些场景下可以用noop调度器加分配器定制把帧放到栈上。我的经验是先别急着优化分配先把生命周期管理做对。等用 profiling 确认协程分配确实是热点再上内存池。过早优化会让调度器代码复杂到难以调试。4.3 谁负责销毁协程帧一个常被忽视的生命周期协议规范上协程帧只有在coroutine_handle::destroy()被调用时才会释放。如果协程执行到final_suspend返回一个挂起状态之后没有任何人调用destroy()这个 frame 就泄漏了。更隐蔽的是如果外层协程co_await内部任务后把Task对象弄丢了析构函数里的destroy()就没机会执行。我建议在调度器设计时定一个铁律协程帧的 owner 必须明确且唯一。要么由协程返回的Task持有帧并负责destroy()要么由调度器在协程进入终态时统一destroy()。不要两个角色都做更不要两个都不做。我自己的调度器里所有协程最终都会落到一个“收尾函数”里最后统一调用句柄清理这样即使协程体里出现异常也能兜底。4.4 调度的效率挂起和恢复的开销到底在哪有人用类似do nothing的空协程做 benchmark发现一个空的协程调用比普通函数慢得多就得出“C20 协程性能差”的结论。这里要拆开看慢的部分大多是启动时的initial_suspend挂起和final_suspend恢复如果你让initial_suspend返回suspend_never{}同时final_suspend返回suspend_always{}但不做恢复那协程的调度开销就是一次普通函数调用加一次状态切换。真正昂贵的是跨线程恢复——一个协程在线程 A 挂起在线程 B 被唤醒这涉及线程间同步和可能的缓存失效。如果你的协程大部分时间都在同一个线程内完成调度性能可以做到非常接近手写状态机。5. 调试协程时的真实踩坑记录三个让我折腾到半夜的问题理论讲完接着来点实战教训。我调试协程时踩过的坑绝大多数不是语法问题而是生命周期和调度顺序问题。这里挑三个最有代表性的每个都是我亲历的真实排错过程。5.1 悬空引用co_await 之后参数引用的对象可能已经没了有一次写一个网络解析协程大致逻辑是从 socket 读出数据解析成一个结构体再交给后续协程处理。代码里直接把一个临时 buffer 的引用传给了另一个协程当时觉得没问题外层协程的局部变量在整个协程生命周期里都有效啊。问题在于“协程生命周期”不等于“栈帧生命周期”。协程在执行co_await时暂停控制权交还给事件循环这时候触发了另一段代码把外层协程正在使用的那个 buffer 对象给释放了。外层协程恢复后继续用这个引用读到的全是野内存。排查过程很痛苦不是每次崩溃而是高并发下偶发。最后用 ASAN 跑压测才抓到。从那以后我给自己立了个规矩协程跨越co_await继续访问的引用类型参数一律改为值传递或共享指针。协程虽然可以看作“可暂停函数”但从生命周期角度它更像一个“可以在任意时间点被外部事件打断的对象”局部变量的存活范围从“函数返回前”变成了“协程帧销毁前”这中间横跨了太多未知操作。5.2 忘记 final_suspend 挂起导致的内存泄漏又一次我在实现某个内部工具时图省事把final_suspend写成了std::suspend_never final_suspend() noexcept { return {}; }测试时内存曲线看着没问题因为短跑任务很快就结束系统回收也不明显。但压测跑了二十分钟后内存占用一路上涨。我当时的直觉是协程帧没释放但没想明白是final_suspend的问题。后来梳理生命周期的握手过程才意识到如果final_suspend返回suspend_never{}协程在函数体执行完、调用return_value后就直接走到协程帧回收逻辑理论上不该泄漏。真正泄漏的原因是协程持有某个资源而资源在协程结束时没有通过析构或清理流程释放。不过这个排查让我额外学到一个点final_suspend之所以通常建议返回suspend_always{}不是为了搞内存泄漏而是为了让外层协程有机会在“当前协程完全结束”这个时刻做一些收尾操作例如恢复等待者、回收调度器资源、记录日志。如果你彻底不需要这些操作suspend_never{}反而更高效。关键在于你要清楚知道自己选的是哪一种。5.3 在协程里直接拿互斥锁谁把锁解开成了大问题协程里最阴间的 bug 是你在协程里std::lock_guard包住一个互斥锁然后中间co_await了。co_await暂停时锁还在lock_guard对象上但lock_guard本身没有跨co_await的机制——它不会被析构自然也不会自动解锁。另一个协程想在同一个锁上排队于是死锁。当时我的代码写得挺自然std::lock_guard lock(mutex_); co_await do_something_async();看起来只是普通加锁后做异步操作实际执行时事件循环线程发现这个协程永远挂起占着锁不放其他协程全部堆积整个系统假死。排查方向一开始完全错了我以为锁的竞争太激烈加了思考还试着调了调度策略结果毫无帮助。后来打印每个协程的挂起点才定位到co_await处。解决办法是改成“协程感知锁”——锁的获取和释放都做成异步co_await操作或者在co_await前显式手动解锁。这事的教训是普通 RAII 类型的资源管理语义在协程里不能想当然地延续。一旦进入 async 世界资源的获取和释放都可能被拆到两个不同的调度周期里必须用异步友好的机制。6. 适合用协程的场景与不适合的场景——用了一年之后的个人判断最后说点更有实用价值的东西什么场景值得用协程什么场景用它纯属给自己找麻烦。6.1 协程真正值得用的地方高并发 I/O、流式处理、异步流程编排最典型的适配场景是网络服务。以我手头一个网关项目为例老实现用的是线程池加回调连接数一多回调嵌套能把人绕晕。改成协程后每个连接的读写流程写成了线性代码逻辑清晰了不是一星半点。线程数也大幅下降压测时同样负载下 CPU 占用没有变高内存分配经过内存池优化后也稳定。流式数据处理也适合。比如一个流水线读文件片段解压转换写输出。每一步都产生一个数据块天然适合用co_yield按需生成不用一次性把整个文件读进内存。C20 的协程在这里充当生成器角色代码比迭代器接口简单得多。异步流程编排则是协程的另一个强项有多个 I/O 任务需要并行发起再汇总结果。用回调写要做when_all的复杂状态管理用协程可以直接co_await几个子任务编译器生成的状态机帮你管理大部分状态。6.2 协程不适合什么深递归、CPU 密集逻辑、生命周期模糊的热点路径协程不是银弹。深递归天然不友好——虽然对称转移可以控制栈增长但每个协程帧是堆分配的递归深层数时内存开销跟着涨性能和栈上递归完全不是一个量级。CPU 密集计算也不要用协程协程调度器不会把计算任务自动分散到多核你只会得到一套更复杂的线程池调用而不是计算加速。生命周期模糊的场景我特别想提醒。协程把“函数调用”变成了“一个可以任意暂停的对象”这意味着所有和“调用栈绑定”的假设都要重新审视。一个接口如果无法回答“谁持有这个协程、何时销毁、能否被安全取消”那它就不适合用协程实现。我见过太多项目生态里塞了几个协程封装后各种资源清理问题像杂草一样长出来就是因为代码边界上没有明确的生命周期归属。6.3 我现在的选择标准跑了一整年协程核心代码后我给自己定了个选择标准如果一个函数只有 1 到 2 个真正会阻塞的 I/O 等待点且等待期间没有复杂资源需要管理那换成协程收益明显如果函数内部有十几处co_await且每个等待点都穿插资源获取释放那说明抽象层次还不够应该先拆函数别硬上协程。调度器的设计也一样。第一版调度器能跑就行等确认业务逻辑完全稳定后再考虑对称转移、内存池这些优化。协程的应用门槛不在语法而在你能不能想清楚每个协程帧的“出生、暂停、恢复、销毁”全部生命周期。这个想通了C20 协程会是你工具箱里非常顺手的一件器械。
阅读完成 · 觉得有帮助?
咨询建站