1. 协程到底是什么
一句话总结: 协程是能在中间挂起、之后从挂起点恢复的函数;它把「调用栈」变成了可以保存和重建的显式数据结构。
普通函数的生命周期是严格嵌套的:被调用者必须在返回前结束,返回后它的栈帧立刻销毁。协程打破了这个规则——它可以在执行到一半时挂起(suspend),把控制权交还给调用者,稍后再从挂起点恢复(resume)继续执行。
async fn fetch_and_sum(id: u64) -> u64 {
let a = fetch(id).await; // 挂起点 1
let b = fetch(a).await; // 挂起点 2
a + b
}
两次 .await 之间,fetch_and_sum 的栈帧必须一直活着——但调用它的调度器早就去执行别的任务了。这意味着协程的「栈帧」不能待在普通的调用栈上,编译器必须为它构造一块可以在挂起期间存活的内存,也就是协程帧(coroutine frame)。
| 特性 | 普通函数 | 协程 |
|---|---|---|
| 执行模型 | 一次调用跑到返回 | 可多次挂起/恢复 |
| 栈帧寿命 | 返回即销毁 | 跨挂起点存活 |
| 状态保存 | 由调用约定自动完成 | 编译器显式建模 |
| 切换成本 | 无 | 一次状态恢复 |
从编译器的角度看,协程的实现只有两条路:给它一块独立的栈(有栈协程),或者把它整个改写成状态机(无栈协程)。
2. 有栈协程与无栈协程
一句话总结: 有栈协程为每个协程分配独立的栈,切换靠换栈指针;无栈协程由编译器把函数体改写成状态机,帧大小在编译期确定。
有栈协程的实现最接近直觉:每个协程有自己的调用栈,挂起就是把当前栈指针保存起来、切换到另一个协程的栈指针,恢复就是换回来。整个过程不需要编译器做任何变换——协程体就是普通函数,只是运行在一段独立的栈上。
// 有栈协程的上下文切换 (示意, x86-64)
typedef struct { void *rsp, *rbp, *rip; } context_t;
void switch_context(context_t *from, context_t *to) {
__asm__ volatile(
"movq %%rsp, %0\n" // 保存当前栈指针
"movq %%rbp, %1\n"
"movq %2, %%rsp\n" // 切换到目标栈
"movq %3, %%rbp\n"
: "=m"(from->rsp), "=m"(from->rbp)
: "m"(to->rsp), "m"(to->rbp));
}
Go 的 goroutine、Lua 的 coroutine 都是这一类。优点是实现简单、递归与嵌套挂起天然支持;缺点是每个协程都要占一块栈(Go 初始 2KB 起,按需增长),协程数量多时内存开销显著。
无栈协程走的是完全不同的路:不给独立栈,而是由编译器把协程体改写成状态机。挂起点变成状态编号,跨挂起点的局部变量被提升到帧对象里,恢复时用 switch 跳回对应状态。
| 维度 | 有栈协程 | 无栈协程 |
|---|---|---|
| 实现方式 | 独立栈 + 换栈指针 | 编译器变换成状态机 |
| 内存开销 | 每协程一栈(KB 级) | 帧大小由编译器计算(百字节级) |
| 挂起位置 | 任意函数深度 | 只能在 await/yield 点 |
| 递归挂起 | 自然支持 | 需要显式 Box / 间接层 |
| 切换成本 | 保存/恢复寄存器 | 一次状态机跳转 |
| 典型语言 | Go、Lua | Rust async、C++20、Kotlin、JS |
3. 状态机变换
一句话总结: 把协程体按挂起点切成若干段,每段是一个状态;局部变量提升进帧,
resume用状态编号做switch跳回上次停下的位置。
状态机变换是无栈协程的核心降级(lowering)。思路很直接:挂起点把函数体切成线性的一段段代码,每段就是一个状态。
// 变换前
async fn fetch_and_sum(id: u64) -> u64 {
let a = fetch(id).await; // 挂起点 1
let b = fetch(a).await; // 挂起点 2
a + b
}
// 变换后 (概念示意)
enum State { Start, AfterFirst, Done }
struct FetchFrame {
state: State,
id: u64, // 参数: 整个生命周期都要用
a: u64, // 跨挂起点 1 活跃
b: u64, // 跨挂起点 2 活跃
}
impl FetchFrame {
fn resume(&mut self, waker: &Waker) -> Poll<u64> {
loop {
match self.state {
State::Start => {
match fetch(self.id).poll(waker) {
Poll::Pending => return Poll::Pending,
Poll::Ready(v) => { self.a = v; self.state = State::AfterFirst; }
}
}
State::AfterFirst => {
match fetch(self.a).poll(waker) {
Poll::Pending => return Poll::Pending,
Poll::Ready(v) => { self.b = v; self.state = State::Done; }
}
}
State::Done => return Poll::Ready(self.a + self.b),
}
}
}
}
关键点有三个:
- 每个挂起点对应一个状态,恢复时直接从该状态继续,不需要重放之前的代码;
- 跨挂起点活跃的局部变量必须进帧——
a在挂起点 1 之后被定义、在挂起点 2 使用,所以它必须住在帧里;而只在单个状态内使用的临时量可以留在栈上; Poll::Pending就是挂起:状态机返回Pending,控制权交还调度器;下次被wake后从同一状态重新进入。
def lower_to_state_machine(func, suspend_points):
"""按挂起点切分函数体,生成状态列表"""
states = []
segment = []
for stmt in func.body:
segment.append(stmt)
if stmt in suspend_points:
states.append(segment)
segment = []
if segment:
states.append(segment)
# 跨段活跃的变量必须提升到帧
frame_vars = set()
for i, seg in enumerate(states[:-1]):
live_across = liveness_at_exit(seg) & used_in_later(states[i + 1:])
frame_vars |= live_across
return states, frame_vars
4. 帧布局与挂起点分析
一句话总结: 帧里该放哪些变量,由「跨挂起点活跃」这一条件决定;帧大小是所有跨挂点活跃变量大小之和,精确分析能显著缩小帧。
帧布局的精度直接决定内存开销。如果一个协程帧把所有局部变量都塞进去,帧会膨胀好几倍;正确做法是做一次活跃性分析,只把「在某个挂起点之前定义、在某个挂起点之后使用」的变量放进帧。
def compute_frame_layout(cfg, suspend_points):
"""逐挂起点计算跨点活跃变量,求并集得到帧布局"""
frame = {}
offset = 0
for sp in suspend_points:
live_in = set()
live_out = set()
for var in cfg.variables:
def_sites = cfg.defs[var]
use_sites = cfg.uses[var]
if any(d < sp for d in def_sites) and any(u > sp for u in use_sites):
live_in.add(var) # 在 sp 之前定义、之后使用
if any(d > sp for d in def_sites) and any(u > sp for u in use_sites):
live_out.add(var)
for var in live_in | live_out:
if var not in frame:
size = cfg.type_size(var)
frame[var] = (offset, size)
offset += size
return frame, offset
这个分析还有一个副产品:帧的对齐。不同变量有不同对齐要求(u64 要 8 字节对齐、u128 要 16 字节),如果按定义顺序紧密排列,编译器需要插入填充字节。把大对齐变量排在前面、小的排后面,能减少填充浪费。
| 优化手段 | 效果 | 前提 |
|---|---|---|
| 只提升跨挂点活跃变量 | 帧缩小 2~5 倍 | 精确活跃性分析 |
| 变量拆分(split) | 只在挂起点保存活跃部分 | 字段级活跃性 |
| 帧内变量重叠复用 | 生命周期不重叠的变量共享槽位 | 区间图着色 |
| 内联短协程 | 直接消除帧 | 内联预算足够 |
值得注意的是,帧布局是跨语言的公共问题:Rust 的 Future 帧、C++20 的 coroutine frame、Kotlin 的 continuation 对象,本质上都是同一件事——把「编译期可见的活跃性分析结果」固化成运行期的内存布局。
5. 帧分配与内存复用
一句话总结: 帧可以分配在堆上也可以内联在调用者里;Rust 的
Future是惰性的、帧大小编译期确定,C++20 默认走operator new,两者都需要Pin或等价机制保证帧地址稳定。
帧分配策略是性能的关键分水岭:
// Rust: Future 是惰性的, 调用 async fn 不执行任何代码, 只构造帧
let fut = fetch_and_sum(42); // 此时没有分配, 帧在栈上
fut.await; // 首次 poll 才开始执行
// 需要把 Future 放进堆 (如 spawn 到执行器) 时显式 Box::pin
let handle = tokio::spawn(async move { fetch_and_sum(42).await });
// spawn 内部做 Box::pin, 帧被移到堆上, 地址固定
Rust 的设计哲学是「零成本抽象」:async fn 返回的 Future 只是一个普通值,帧的大小在编译期完全确定,如果调用者把它放在栈上,就不产生任何堆分配。代价是 Future 通常是 !Unpin 的——因为帧里可能包含自引用结构(比如一个迭代器持有指向帧内缓冲区的指针),一旦帧被移动,这些指针就悬空了。Pin<P> 的作用就是在类型系统层面保证「这个值不会再被移动」。
// 自引用帧: 指针指向帧内字段, 帧移动则悬空
struct SelfRefFrame {
buf: [u8; 64],
cursor: *const u8, // 可能指向 buf
}
// 所以需要 Pin<&mut SelfRefFrame> 来禁止移动
C++20 的协程走的是另一条路:标准规定了 operator new 分配协程帧,编译器在入口处调用它、在协程结束时调用 operator delete。这意味着默认就有一次堆分配——除非编译器能证明帧不会逃逸出调用者,做「帧消除」(frame elision),或者用户重载了 operator new 使用池化分配器。
// C++20: 为协程类重载 operator new, 用内存池避免每次堆分配
struct Task {
struct promise_type {
void* operator new(std::size_t sz) { return frame_pool.allocate(sz); }
void operator delete(void* p, std::size_t sz) { frame_pool.deallocate(p, sz); }
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
};
| 语言 | 帧分配默认策略 | 消除堆分配的手段 |
|---|---|---|
| Rust | 惰性,栈上直到被 Box::pin | 不 spawn、执行器栈式调度 |
| C++20 | operator new 堆分配 | 帧消除、重载 operator new、-fcoroutines |
| Kotlin | 编译期生成 continuation 类 | 挂起函数内联、尾调用优化 |
| Go | 独立栈,按需增长 | 栈复用、sync.Pool |
6. 与垃圾回收的交互
一句话总结: 无栈协程的帧是普通堆对象,GC 天然可扫描;有栈协程的独立栈需要被 GC 识别为根,栈增长或移动时还要处理栈内指针的更新。
协程与 GC 的交互,取决于协程的实现路径:
无栈协程没有这个问题。帧就是一个普通的堆对象,里面装的都是普通引用,GC 按常规方式扫描它即可。Rust 甚至不需要 GC——帧的所有权由类型系统管理,Future 被 drop 时帧自然释放。Kotlin/JVM 的协程帧是 continuation 对象,JVM 的 GC 会像扫描其他对象一样扫描它。
有栈协程就麻烦得多。GC 必须把每个协程的栈当成根集合的一部分来扫描,这要求:
- 栈上的指针必须可精确识别。保守式 GC 会把「看起来像指针的整数」误认为引用,导致对象无法回收;精确式 GC 需要编译器为每个栈帧生成栈映射(stack map),标明哪些槽位是指针。
- 栈增长或移动时必须更新栈内指针。Go 的 goroutine 栈按需增长:当栈空间不够时,Go 运行时会分配一块更大的栈、把旧栈内容复制过去、然后修正所有指向旧栈的指针。这个「栈复制」要求编译器知道每个栈帧里哪些位置是指针,同时还要保证运行在栈上的代码可被安全地重定位。
- 跨协程的引用需要写屏障。如果 A 协程的栈上引用指向 B 协程栈上的对象,而 B 的栈被复制了,A 的引用就必须被更新——这要求这类引用也被纳入 GC 的跟踪范围。
def scan_coroutine_stack(g, stack_map):
"""有栈协程: 按栈映射精确扫描栈上的引用"""
refs = []
for frame in g.stack_frames:
slots = stack_map[frame.pc] # 该 PC 处的栈映射
for offset in slots.pointer_offsets:
addr = frame.sp + offset
refs.append(read_pointer(addr)) # 精确取引用, 不误判整数
return refs
这也是 Go 选择「栈可增长、但用精确栈映射」的原因:精确映射让 GC 和栈复制都能正确工作,代价是编译器必须为每个可能的返回地址维护一份栈映射表。
7. 工程实践与常见坑
一句话总结: 三大高频坑是帧膨胀、递归 async 导致的无限类型、以及取消与析构的语义;用
Box、内联和显式 drop 边界逐一化解。
// 坑 1: 递归 async -> 无限大的类型, 编译报错
async fn recurse(n: u32) -> u32 {
if n == 0 { 0 } else { recurse(n - 1).await + 1 }
}
// error: recursive async fn ... recursive type has infinite size
// 解决: 用 Box::pin 把帧变成间接的堆指针
fn recurse(n: u32) -> Pin<Box<dyn Future<Output = u32> + Send>> {
Box::pin(async move {
if n == 0 { 0 } else { recurse(n - 1).await + 1 }
})
}
- 帧膨胀。一个协程帧可能包含几十个变量,如果其中还嵌套着其他
Future(await一个async fn会把内层帧内联进外层帧),帧大小会层层累加。控制手段是及时Box::pin切断内联,或者用-Zprint-type-sizes(Rust nightly)观察每个 Future 的实际大小。 - 递归 async。如上例,
async fn递归会让类型大小无限展开,必须引入Box或 trait object 打断递归。 - 取消与析构。协程被取消时,帧会被 drop,此时挂起点上的资源(文件句柄、锁、事务)需要正确释放。Rust 靠 RAII 保证,但
select!这类会取消分支的宏要求取消安全的代码;C++20 则需要在unhandled_exception与析构里显式处理。 Send约束。把Future跨线程移动要求它Send,而只要帧里持有一个Rc或裸指针,整个Future就不再Send——这类错误信息往往指向编译器生成的匿名帧类型,非常难读,需要靠-Zprint-type-sizes或缩小await作用域来定位。
// 坑 4: 跨 await 持有非 Send 值, 导致整个 Future 不 Send
async fn bad() {
let rc = std::rc::Rc::new(1);
something().await; // rc 跨过 await, Future 不再是 Send
println!("{}", rc);
}
// 解决: 缩小 rc 的作用域, 让它在 await 之前 drop
8. 总结
| 概念 | 要点 |
|---|---|
| 协程本质 | 可挂起/恢复的函数,帧须跨挂起点存活 |
| 有栈协程 | 独立栈 + 换栈指针,实现简单、内存开销大 |
| 无栈协程 | 编译期改写成状态机,帧大小静态确定 |
| 状态机变换 | 按挂起点分段,resume 用状态编号 switch 跳回 |
| 帧布局 | 只提升跨挂点活跃变量,注意对齐与槽位复用 |
| 帧分配 | Rust 惰性栈上,C++20 默认堆分配,可池化 |
Pin | 保证自引用帧地址稳定,禁止移动 |
| 与 GC 交互 | 无栈帧是普通对象;有栈栈需精确栈映射与指针修正 |
| 常见坑 | 帧膨胀、递归 async、取消安全、Send 约束 |
协程的编译降级是「用编译期的复杂度换运行期的效率」的典范:无栈协程把栈的管理责任从运行期搬到了编译器,换来的是极小的帧与零成本的挂起;代价是挂起点受限、递归需要显式处理、以及自引用带来的 Pin 这类类型系统负担。理解了状态机变换与帧布局这两件事,再看任何语言的 async/await,都能一眼看穿它在运行时到底长什么样。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。