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

协程的魔法:一个挂起点,一个状态机

协程的魔法:一个挂起点,一个状态机 ★ FEATURED ARTICLE
① 钩子一个 co_await一整套状态机你在函数里写一个co_await编译器就要给你生成一整套状态机一个堆分配的协程帧存局部变量 状态字段一个记录上次停在哪的状态号一个resume 函数.Frame.actor恢复时按状态号跳回正确的挂起点继续执行。一个挂起点 状态机里的一次存档。这一集我们把协程的魔法拆成机器码帧、状态字段、resume/destroy 函数指针、switch(state) 分发——全部眼见为实。② 源码 vs 汇编对照structTask{structpromise_type{Taskget_return_object(){return{};}std::suspend_neverinitial_suspend(){return{};}// 立即开始执行std::suspend_alwaysfinal_suspend()noexcept{return{};}voidreturn_void(){}voidunhandled_exception(){}};};Taskwith_suspend(){co_awaitstd::suspend_always{};co_awaitstd::suspend_always{};co_return;}这里用-O0看机械结构。反例-O2下这个无副作用的协程被整个优化成空生成的汇编只剩几行ret——协程同样受无可观察副作用 → 死代码消除规则约束。要稳定看到状态机得让协程做点事写全局、I/O。协程入口 —— 隐藏的堆分配 初始化帧with_suspend(): movl $32, %eax ; 帧大小32 字节 call _Znwy ; operator new —— 协程帧在堆上分配 ... leaq .Frame.actor(%rip), %rdx ; 帧里存继续执行函数指针 movq %rdx, (%rax) leaq .Frame.destroy(%rip), %rdx ; 帧里存销毁函数指针 movq %rdx, 8(%rax) movw $0, 18(%rax) ; 状态字段初始 0 ... call .Frame.actor ; 开始执行进入状态机 ... call _ZdlPvy ; 结束 → 释放帧operator deleteresume 函数状态机—— 按上次停在哪分发.Frame.actor: movzwl 18(%rax), %eax ; 读状态字段 andl $1, %eax testw %ax, %ax je ... ; 未暂停 → 从头执行 cmpl $9, %eax ; switch(state) je .L34 ; 状态 9 → 某个挂起点继续 cmpl $7, %eax je .L33 ; 状态 7 → 另一挂起点 cmpl $5, %eax je .L32 cmpl $3, %eax je .L31 cmpl $1, %eax je .L51 ; 状态 1 → 第一个 co_await 之后编译器把两个co_await之间的代码切成多个片段每个片段对应一个状态恢复时按state跳回正确的片段。这就是协程是编译器生成的状态机这句话的机器码形态。③ 为什么这么设计协程的本质把可以中途暂停、之后从暂停点继续翻译成状态机。挂起点 状态号恢复 按状态跳转。为什么帧要堆分配协程暂停后它的局部变量要活到下次恢复但普通函数的栈帧随调用返回就销毁了。所以编译器把局部变量挪到堆上的帧里call _Znwy分配 32 字节。现代实现有帧分配消除优化——帧能安全放栈上时就不分配堆。resume 函数 状态机.Frame.actor里switch(state)跳到对应挂起点。恢复调用时传帧指针函数从上次退出的位置继续。为什么需要 destroy 函数指针协程可能在任何挂起点被销毁不继续执行destroy负责释放帧 运行析构——这也是为什么帧里存两个函数指针。为什么-O2会删光协程没有可观察副作用没有 I/O、没有写内存整段可被死代码消除。看不到状态机 ≠ 没有状态机而是它什么都没做没必要存在。④ 深入一帧里到底存了什么协程帧是局部变量的堆化版本。一个典型帧的布局偏移 0 resume 函数指针.Frame.actor 偏移 8 destroy 函数指针.Frame.destroy 偏移 16 promise 对象用户 promise_type ... 偏移 18示例 状态字段suspend 点编号 ... 局部变量每个 co_await 之间活过暂停点的变量函数指针 状态字段是协程的运行时身份resume 用它继续、destroy 用它释放局部变量只在暂停后还要用的变量才进帧编译器做活跃性分析能栈上/不跨越暂停点的变量不进帧promise用户自定义的 promise_type 提供 get_return_object / initial_suspend / return_void 等钩子——编译器据此生成帧的构造与各阶段调用。所以协程魔法的本质普通函数把局部变量放栈随调用销毁协程把它们搬到堆上的帧跨越暂停存活再用状态机记录执行到哪。⑤ 深入二协程 vs 线程 vs 函数——三种执行单元维度普通函数协程线程暂停/恢复无有协作式有抢占式切换成本call/ret状态机跳转纳秒级内核切换/上下文微秒级局部变量存活栈随返回销毁堆帧跨暂停存活栈线程栈调度无用户代码主动让出操作系统调度关键协程是更轻的线程——单线程内通过状态机让出/恢复无内核切换、无线程栈分配。所以协程 协作式、超轻量。但要警惕协程里做阻塞操作sleep、阻塞 I/O会卡住整个协程调度器因为它是协作式——这是和线程最大的行为差异协程帧的堆分配 恢复间接调用是隐藏成本——CPU 密集短任务用协程未必划算。⑥ 常见误区误区 1“协程就是异步线程”协程是协作式主动让出线程是抢占式内核调度。语义、成本完全不同。误区 2“协程零开销”有帧堆分配 状态机间接调用。优化好的实现能省帧分配但不是没有。误区 3“-O2下协程退化成普通函数”-O2是没副作用才删光真实协程有 I/O/状态-O2下仍是状态机只是更紧凑。误区 4“协程能替代多线程”协程是并发模型之一适合 I/O 密集CPU 密集并行仍需线程/线程池。误区 5“co_await只是语法糖”它触发整个 promise/awaitable/状态机机制是编译器级的代码生成不只是展开。误区 6“协程帧自动释放不用管”帧由句柄/调度器管理若协程挂起后句柄被丢弃且没走到 final_suspend 清理帧会泄漏。RAII 包装句柄是标准做法。误区 7“协程就是更快的回调”协程把手动状态管理变成编译器生成状态机代码更接近同步顺序流但它仍有帧分配与恢复间接调用——不是免费取代回调而是更不易错的异步写法。误区 8“co_await一个完成的操作也会挂起”不一定——awaitable 的await_ready()若返回 true已完成则不挂起直接继续。所以等待已完成操作零挂起开销恢复路径很轻。⑦ 实战启示协程有隐藏成本帧的堆分配 恢复时的函数指针间接调用。它适合 I/O 密集、网络/异步、事件驱动场景CPU 密集的短任务用它收益不大。追求零分配协程用帧可省略/可栈上化的实现部分库和编译器的帧优化或尽量让协程帧小、生命周期简单。别把协程当更快的线程协程是协作式调度主动让出不是抢占式。协程里做阻塞 I/O 或sleep会卡住整个调度器。想清楚局部对象的生命周期挂起点之后对象必须仍然有效——常配合 future/awaitable 传递结果。选对协程库C20 协程是语言机制任务调度/IO 复用由库提供如 cppcoro、Boost.Asio、各家框架。语言只管状态机库管谁恢复谁。⑧ 扩展专题一co_await 的完整展开promise/awaitable 协议co_await expr的完整展开大致是求值expr得到 awaitable调用await_suspend(handle)若返回 true 则挂起或await_ready()true 则不挂起直接继续挂起时状态字段记录当前位置 → 帧存好 → 把控制权交还调用方恢复时.Frame.actor按状态跳回 →await_resume()返回结果 → 继续执行。promise_type 提供协议钩子initial_suspend是否立即执行、final_suspend结束时机、return_void/return_value、unhandled_exception、get_return_object。机器层面这些钩子都是普通函数调用/内联——编译器把整个协议编织进状态机代码里。所以协程框架的灵活性来自 promise 协议代价是每次 co_await 多几层判断。⑨ 扩展专题二协程与 E12 移动语义/RAII 的配合协程帧里的局部变量、参数按移动到帧处理E12 讲过移动语义参数/局部变量被移动进帧可能用移动构造需要可移动类型帧的销毁触发各局部变量的析构RAII 保证资源释放promise 对象也在帧里生命周期随帧。陷阱协程里持有引用/指针到帧外对象时要保证那些对象活得比协程久否则悬垂。co_await后继续使用引用 依赖帧外对象还活着——这和 E11 的闭包捕获引用悬垂是同一类风险。⑩ 扩展 FAQQ协程帧一定在堆上吗AC20 默认operator new分配堆但实现可做帧分配消除帧无需跨越调用方存活时放栈上。看-O2汇编里有没有_Znwy就能判断。Qco_yield和co_await区别Aco_yield是产出值并挂起生成器场景co_await是等待异步结果。两者都走 promise/状态机机制。Q协程能嵌套吗A能——协程 A 里co_await协程 B 返回的 Task。帧各自独立恢复时沿调用链回跳。Qstd::generatorC23是什么A标准生成器惰性产出序列用协程实现。for (auto x : gen())每次迭代co_yield一个值——状态机每步存档/恢复。Q协程性能比回调好在哪里A回调要手动保存/恢复上下文闭包、函数指针协程由编译器生成状态机代码更像同步顺序流且帧生命周期由 RAII 管理少了手动状态机的错误面。⑪ 扩展实验看帧分配g -stdc20 -O0 -S E18_coro.cpp确认_Znwy/_ZdlPvy帧的堆分配与释放。数状态两个co_await→ 数.Frame.actor里cmpl分支数状态号 1/3/5/7/9本集汇编可见。-O2反例无副作用的协程-O2 -S确认被整个优化空。让协程做事协程里写全局变量/printf-O2 -S看状态机是否保留。generator 实验C23std::generator或手写生成器遍历 反汇编看每次co_yield的存档/恢复。⑬ 扩展专题三状态机优化的规模——多个挂起点的真实成本一个协程有多少个挂起点就切多少段。本集两个co_await切出了状态 1/3/5/7/9 五档含边界。真实协程的教训挂起点越多switch(state)的分支越多虽然cmpl链在编译器眼里会优化成跳转表每次恢复都要读状态 → 分发 → 跳转挂起点密度越高间接成本越明显设计启发不是协程越多越好。把大协程拆成小步、少挂起的单元比一个巨型状态机更利于优化和理解。恢复路径的汇编.Frame.actor开头movzwl读状态→cmpl链 → 对应jmp。这套读表-跳转就是协程和普通函数的本质差异——普通函数从函数头开始协程从上次停的地址开始。⑭ 扩展专题四协程 vs 生成器 vs 异步任务——选型三种协程形态对应三种用途形态语法用途帧特点Task本集co_await异步 I/O、单次结果帧存状态 局部变量Generatorco_yield惰性序列、流式帧存迭代状态Async taskco_await 库调度并发任务编排帧 调度器协作I/O 密集异步→ Task 协程co_await 等 I/O不让出 CPU 的等待惰性生成序列→ Generator每步 co_yieldCPU 并行→ 线程/线程池协程帮不上抢占式并行。一句话协程是协作式并发的语言机制选型看你是在等 I/O、产出序列、还是真并行。⑮ 扩展 FAQ第二轮Q协程帧的分配能复用/池化吗A能——自定义分配器/帧池可减少分配抖动。promise_type的operator new可定制配合线程局部池。Qco_await挂起后调用栈会怎样A调用栈退栈当前函数返回了帧留在堆上。所以协程比线程省栈——线程要预留大栈协程帧按需分配。Q协程里能用异常吗A能unhandled_exception接收异常可在恢复点抛出。协程的异常传播也走 promise 协议RAII 析构照常执行。Qnoexcept协程怎么写ATask f() noexcept——但注意 promise 的分配/构造仍可能抛bad_alloc。noexcept 主要约束函数体不主动抛。Q协程和std::async区别Astd::async是线程池/新线程的真并行协程是单线程协作。I/O 等待用协程不占线程CPU 计算用 async占核。⑯ 扩展实验第二轮帧大小测量协程里放不同大小的局部变量-O0 -S看_Znwy的分配大小movl $N, %eax变化。状态机跳转表-O2下协程带副作用反汇编看cmpl链是否变成跳转表jmp *table。异常传播协程里 throwunhandled_exception捕获反汇编看异常路径与 RAII 析构。帧池化自定义 promise 分配器帧池对比频繁创建协程的分配次数。generator 步进实现/使用 generator反汇编每次co_yield的存档恢复点。⑱ 扩展专题五协程帧的活跃性分析——什么变量才进帧编译器不是把协程所有局部变量都塞进帧——它做活跃性分析跨暂停点仍被使用的变量→ 必须进帧否则恢复后读不到只在两个暂停点之间使用、不跨越暂停点→ 可以留在寄存器/栈不进帧参数co_await之前要用的参数通常被拷贝/移动进帧因为调用方栈已返回。Taskf(){inta1;// 不跨暂停点 → 可能不进帧intb2;// 跨暂停点 → 进帧co_awaitsomething();use(b);// b 在暂停后还要用必须活下来}机器含义帧大小 “跨暂停点存活的变量” 的体积。协程里少放跨挂起还要用的大对象帧就小、分配就便宜——这是协程帧要小的工程依据。⑲ 扩展专题六协程与栈展开的区别——帧不是栈一个常见混淆协程帧在堆上它是不是另一种栈不是栈LIFO随函数调用自动分配/释放地址连续、硬件支持协程帧堆上单块存跨暂停的局部变量 状态 函数指针生命周期由协程句柄管理RAII/手动 destroy恢复不是返回上一帧.Frame.actor是按状态跳转不是弹出栈帧。为什么协程省内存线程要预留整条栈通常几 MB协程只需按需分配的小帧。所以百万级并发连接用协程/异步是可行的用线程则栈内存爆炸——这是协程最大的实际价值之一。⑳ 扩展 FAQ第三轮Q协程能否用于 CPU 密集并行A不能替代线程并行。协程是单线程协作式CPU 密集要靠多线程/线程池占核。混用场景协程做 I/O 编排 线程池做计算。Qco_await一个非协程函数可以吗A可以只要它返回可等待类型有 await_ready/await_suspend/await_resume或能转成它。std::future等标准类型有适配。Q协程帧什么时候销毁A协程执行到final_suspend挂起后由调用方句柄/调度器调用 destroy或co_return走完自动清理。不销毁帧 泄漏所以 RAII/句柄管理很重要。Q协程能递归吗A协程可以调用自身每次生成新帧但深度递归协程会堆叠多帧内存可能增长——递归协程要小心帧数量。QC20 协程和 C 回调风格如何互操作A协程帧/句柄可以桥接回调 API回调触发时 resume 协程。这是异步库 协程的常见接线模式awaitable 包一层回调。㉑ 扩展实验第三轮活跃性实验改协程让大对象跨挂起vs不跨挂起-O0 -S对比_Znwy分配大小。帧泄漏演示创建协程后不 destroy/不 resume 到底观察内存增长用句柄正确管理后不泄漏。future 适配co_await std::future看标准库如何把 future 适配成 awaitable。回调桥接手写一个回调触发 resume的 awaitable理解协程与回调的接线。递归协程递归调用协程并限制深度观察帧数量与内存。㉒ 扩展 FAQ第四轮Qco_await的调度语义是谁定的Aawaitable 的await_suspend返回值决定返回 void 立即挂起、返回 bool 条件挂起、返回另一个句柄 转交控制权。调度策略是库/awaitable 实现的语言只给机制。Q协程能跨线程恢复吗A能——协程句柄可以在任意线程 resume调度器决定在哪恢复。所以协程 线程池能组合协程编排 I/O恢复点落到线程池执行。Q为什么说协程适合 I/O 密集AI/O 等待不占 CPU协程让出后其他协程/任务可跑而 CPU 密集任务让出也没用还是要算。所以协程的收益在等待而不在计算。Q协程和std::jthread怎么选A并发编排/异步 I/O → 协程轻量、协作真正并行计算/阻塞任务 → 线程jthread可自动 join。混用时用协程做编排、线程池做计算。㉓ 扩展实验第四轮帧泄漏实测创建协程句柄后不销毁反复创建观察内存增长RAII 管理后内存稳定。跨线程恢复用两个线程轮流 resume 同一协程句柄验证协程可在任意线程恢复。await_suspend 语义写返回 void/bool/句柄三种 awaitable反汇编看控制权转交差异。jthread 对照同一任务用协程 vsstd::jthread实现对比创建/调度成本。大帧 vs 小帧帧里放 1 个 int vs 1 个大 struct_Znwy分配大小对比体会帧大小决定分配成本。㉔ 悬念编译器能生成状态机、能去递归、能内联、能删掉无副作用的协程——它的激进建立在什么之上下一集 E19我们看编译器的一整套优化手术然后 E20揭晓它激进过头时未定义行为的代价。㉒ 悬念编译器能生成状态机、能去递归、能内联、能删掉无副作用的协程——它的激进建立在什么之上
阅读完成 · 觉得有帮助?
咨询建站