C++ 协程机制:可暂停函数的底层原理
本文要回答的问题
- "暂停一个函数"到底是怎么做到的?局部变量暂停期间放哪了?
- 编译器收到
co_yield/co_await之后,把函数体改成了什么? - C++20 的无栈协程和线程、和有栈协程,本质差别在哪?
- 协程的切换为什么比线程快?帧分配又贵在哪?
- 协程真的"零成本"吗?编译器会做哪些优化?
一、协程是什么:一个能暂停、能续跑的函数
普通函数有个硬规矩:一旦开始执行,要么跑完返回,要么死循环,没有中间态——调用栈上的局部变量随函数生命周期生灭。协程(coroutine)打破这条规矩:它可以在中途"暂停"(suspend),把控制权交还调用者,之后由调用者(或某个调度器)再次"恢复"(resume),从暂停点继续往下执行,局部变量原样还在。
这个能力对异步编程是刚需:网络请求发出去后,等响应的这段时间不该干瞪眼;用协程写,代码读起来是顺序的(像同步),执行上却是可挂起的(像回调)。C++20 用三个新关键字把协程带进标准:co_await(等待一个异步操作)、co_yield(暂停并吐出一个值,生成器场景)、co_return(结束并返回)。
但"暂停一个函数"在底层不可能是魔法。问题很实在:函数暂停时,它的局部变量必须找个地方存起来,等恢复时原样取回。答案在下一节。
二、核心机制:协程帧 + 状态机
C++20 协程的实现方案可以概括成三件事,全都发生在编译期,运行期只有数据结构和跳转:
- 协程帧(coroutine frame):编译器把协程的局部变量、暂停点信息、promise 对象打包成一个帧,通常分配在堆上。函数暂停 = 帧留着不动;函数返回 = 帧销毁。帧是协程的"第二个栈",只是它只存这一个协程的变量。
- promise 对象:帧的一部分,协程与调用者的"联络员"——
co_yield的值经它传递,co_return的返回值也经它传递,每个协程类型自定义自己的 promise。 - 函数体改写:编译器把协程函数体翻译成一个状态机——每个暂停点是一个状态标签,恢复时从标签处继续。这就是著名的"协程 = 状态机"。
手工验证一下第三点(完整代码见 demos/cpp-expert/coroutine-mechanism/,g++ 4.8.5 实测)。下面是一个手工写的"状态机版协程":IntGen 依次吐出 1、2、3,resume() 就是编译器给 co_yield 生成的"恢复函数"——switch (st_) 按状态跳转,return 即暂停,case ST_A: 即恢复点:
class IntGen {
enum State { ST_INIT, ST_A, ST_DONE }; // 状态标签
State st_ = ST_INIT;
int counter_ = 0; // 局部变量,跨 resume 存活(协程帧的模拟)
int value_ = 0;
public:
void resume() {
switch (st_) {
case ST_INIT:
counter_ = 0;
for (;;) {
if (counter_ >= 3) { st_ = ST_DONE; return; } // co_return
value_ = counter_ + 1;
st_ = ST_A; // 记录暂停点
return; // co_yield:暂停
case ST_A: // 恢复点:从这里继续
++counter_;
}
case ST_DONE:
return;
}
}
int value() const { return value_; }
bool done() const { return st_ == ST_DONE; }
};运行输出(与 C++20 标准协程写法 cxx20.cpp 的输出完全一致):
yield 1
yield 2
yield 3
done这套代码就是教科书式的 Duff's device 技巧,也是 C++20 协程编译产物的同构简化版。注意两个点:counter_ 在多次 resume() 之间存活——这就是协程帧要干的事;st_ 记录"下次从哪继续"——这就是状态机。C++20 里同样的事长这样:
Generator gen() {
int counter = 0;
for (;;) {
if (counter >= 3) co_return;
co_yield counter + 1; // 编译器在这里插入:帧保存 + 状态标签 + return
++counter;
}
}co_yield 一句,编译器替你做完了手工版里 st_ = ST_A; return; 的全部事——协程语法是甜的,机制是实打实的状态机。
三、无栈协程:C++20 协程的本质
C++20 协程是无栈协程(stackless coroutine),这个名字常让人困惑:它明明有"帧"啊,怎么叫无栈?区分点在于会不会按调用深度消耗系统栈:
- 有栈协程(stackful):每个协程拥有完整的独立栈(通常几十 KB 起步),暂停/恢复 = 整个栈的切换(换栈指针)。协程里可以随便深调用其他函数,因为有自己的栈。Go 的 goroutine、Boost.Context 属于这类。
- 无栈协程(stackless):协程没有自己的栈,暂停/恢复 = 保存/恢复协程帧(只有局部变量,没有调用栈)。代价:不能在协程内阻塞式调用其他协程——
co_await一个协程不是"调用",是"挂起自己,把控制权交给调度器"。

上图按"切换成本"排了三档:线程最重(内核参与),有栈协程居中(用户态换栈,但栈内存大、缓存亲和差),无栈协程最轻(只搬帧)。C++20 选择无栈,换来的是帧可以小到几个寄存器的大小、切换开销接近普通函数调用,代价是"协程不能嵌套调用普通深递归函数"的模型约束——这正是异步编程需要的形态。
四、co_await 机制:awaitable 与 awaiter
co_await expr 是协程的核心语法,机制分三层。expr 可以是 awaitable(可直接等待的东西,如 std::future),也可以是 awaiter(实现了三个接口的"等待器")。编译器处理 co_await e 时会问三个问题,对应 awaiter 的三个成员:
await_ready():结果是不是已经好了?好了就直接拿(await_resume()),一次函数调用都不挂起——这是协程快的关键:异步操作已完成时,co_await 退化成普通表达式。await_suspend(handle):没好,需要挂起。这里决定"挂起后把控制权交给谁":返回void(继续执行当前线程的调用者)、bool(true 挂起 false 不挂起)、或另一个协程句柄(对称转移,把控制权直接交给另一个协程,不绕回调度器)。await_resume():恢复时取回结果(或抛异常)。
这套"一问三答"的协议就是协程与异步 IO 之间的胶水:网络库的 co_await socket.read() 内部就是 await_suspend 把事件注册到 epoll/io_uring,等 IO 完成后再由事件循环 resume 这个协程。协程本身不碰内核,挂起和恢复全在用户态完成——这是它能以低延迟支撑百万级并发连接的根因。
五、生命周期与调度:一个协程的一生
从调用者视角看一个协程的完整生命周期:
- 创建:调用协程函数(如
gen())→ 分配协程帧(operator new)→ 构造 promise → 返回get_return_object()给调用者(通常是个句柄包装)。 - 首次挂起:
initial_suspend()决定是否立刻挂起(suspend_always:先不跑,等调度器来 resume;suspend_never:立即跑到第一个 co_await/co_yield)。 - 运行/挂起循环:
resume()→ 跑到暂停点 →await_suspend/co_yield挂起 → 交还控制权;调度器空闲时再来resume()。 - 结束:
co_return→ 调final_suspend()→ 帧销毁(通常由持有句柄的一方destroy(),或在句柄析构时)。
句柄 std::coroutine_handle 就是指向帧的指针,是调用者与协程之间的唯一通道。生命周期错误(忘了 destroy、句柄悬垂)是协程最经典的坑——帧是堆上的,没人负责就泄漏。
六、性能账:切换便宜,分配不便宜
协程的性能画像要分开看:
切换(暂停/恢复):无栈协程的切换是用户态的一串寄存器保存/恢复 + 状态跳转,成本量级是几十纳秒,比线程切换(几微秒,含内核陷入与调度)低两个数量级。实测对比:
| 维度 | 线程切换 | 有栈协程切换 | C++20 无栈协程切换 |
|---|---|---|---|
| 是否进内核 | 是(调度器) | 否 | 否 |
| 需要换栈 | 是(几 KB~几 MB) | 是(几十 KB) | 否(只搬帧) |
| 典型开销 | 2~10 µs | 100 ns 级 | 几十 ns 级 |
| 缓存友好 | 差(栈冷) | 中 | 好(帧小) |
帧分配:协程帧默认堆分配(new 一次),这是协程的"隐藏税"。编译器有两招来消除:对称转移(协程间直接交接控制权,不经过中间层)和 HALO(Heap Allocation eLision Optimization:协程帧若在调用栈内创建且不逃逸,可直接分配在栈上)。手工实验能看到同源优化的威力——把上一节的手工状态机用 -O2 编译,反汇编里 resume 函数整个消失、循环被拍平:
4004b3: lea 0x1(%rbx),%esi # 算下一个值
4004c0: callq 400470 <printf@plt>
4004c5: cmp $0x2,%ebx # 到 3 了?
4004c8: jle 4004b3 # 没到继续状态机结构对编译器是透明的,优化器能把它折叠成普通循环——协程的"抽象"不挡优化,这正是它敢自称接近零成本的原因。但 HALO 只在简单场景生效,真实协程(尤其带捕获、跨函数)堆分配依然常见,perf 里看到 operator new 在协程路径上别奇怪。
七、代价与陷阱
协程不是银弹,三笔账要记清:
- 帧的生命周期要人管:堆帧、句柄悬垂、忘了
destroy()就是泄漏;co_await一个会抛异常的 awaitable,异常路径也要 handle。 - 无栈限制:不能在一个协程里阻塞式地
co_await任意深度的嵌套协程链(依赖对称转移与调度器协作),也不能在协程里用会依赖栈的结构(如 setjmp/longjmp)。 - 编译与调试成本:协程展开后符号复杂(
__coro相关符号、帧类型),错误信息与调试体验在 C++20 初期都一般;性能剖析时看到协程符号要能认出来。
八、与本站性能主线衔接
协程是本站"网络低延迟"域(epoll 事件循环、io_uring)的天然搭档:epoll/io_uring 提供内核侧的高效 IO 通知,协程提供用户侧的轻量挂起,两者结合可以支撑每线程十万级并发连接而不用"一连接一线程"。剖析协程程序有几个落点:
perf火焰图里如果operator new/__gxx_coroutine*成热点,先看帧分配能不能被 HALO 消除,或改用可复用帧的池化写法——帧分配是协程路径上最像"普通 malloc"的开销。- 切换开销极小,不必优化;真正要盯的是每次 resume 后的缓存缺失——帧小而分散时,高频协程切换会打乱指令/数据局部性。
- 无栈协程与 内存序与 atomic 结合时要注意:调度器与协程之间的同步(句柄、状态标志)通常用原子操作,别在协程里写裸共享数据。
回到开头的问题:协程能暂停,靠的是堆上协程帧保存局部变量 + 编译器把函数体改写为状态机;无栈意味着切换只需搬帧(几十 ns),帧分配是主要隐藏成本,编译器用 HALO 尽量消除。它不比线程"神奇",只是把账算得更精细——用用户态几十纳秒的切换,换内核几微秒的调度。
一句话总结
C++20 协程 = 无栈协程:局部变量进堆上的协程帧(promise 在其中),编译器把函数体改写成带暂停标签的状态机,co_await 走 await_ready/await_suspend/await_resume 三问协议——切换是用户态搬帧(几十 ns,比线程快两个量级),帧分配是主要隐藏成本(HALO 可消除),不能嵌套深调用是它的模型约束;异步 IO(epoll/io_uring)加协程,是低延迟高并发服务的标准组合。
上一篇:RTTI 与 typeid 下一篇:concepts 概念