协程与 async 降级

协程编译器把可挂起的函数改写成状态机。本文对比有栈与无栈协程的实现路径,推导状态机变换与帧布局,讲解挂起点分析、跨挂点变量提升与帧内存复用,再落到帧分配策略、与垃圾回收及栈复制的交互,以及 C++20、Rust、Go 的工程实践、取消语义与常见坑。

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、LuaRust 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),
            }
        }
    }
}

关键点有三个:

  1. 每个挂起点对应一个状态,恢复时直接从该状态继续,不需要重放之前的代码;
  2. 跨挂起点活跃的局部变量必须进帧——a 在挂起点 1 之后被定义、在挂起点 2 使用,所以它必须住在帧里;而只在单个状态内使用的临时量可以留在栈上;
  3. 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++20operator new 堆分配帧消除、重载 operator new、-fcoroutines
Kotlin编译期生成 continuation 类挂起函数内联、尾调用优化
Go独立栈,按需增长栈复用、sync.Pool

6. 与垃圾回收的交互

一句话总结: 无栈协程的帧是普通堆对象,GC 天然可扫描;有栈协程的独立栈需要被 GC 识别为根,栈增长或移动时还要处理栈内指针的更新。

协程与 GC 的交互,取决于协程的实现路径:

无栈协程没有这个问题。帧就是一个普通的堆对象,里面装的都是普通引用,GC 按常规方式扫描它即可。Rust 甚至不需要 GC——帧的所有权由类型系统管理,Future 被 drop 时帧自然释放。Kotlin/JVM 的协程帧是 continuation 对象,JVM 的 GC 会像扫描其他对象一样扫描它。

有栈协程就麻烦得多。GC 必须把每个协程的栈当成根集合的一部分来扫描,这要求:

  1. 栈上的指针必须可精确识别。保守式 GC 会把「看起来像指针的整数」误认为引用,导致对象无法回收;精确式 GC 需要编译器为每个栈帧生成栈映射(stack map),标明哪些槽位是指针。
  2. 栈增长或移动时必须更新栈内指针。Go 的 goroutine 栈按需增长:当栈空间不够时,Go 运行时会分配一块更大的栈、把旧栈内容复制过去、然后修正所有指向旧栈的指针。这个「栈复制」要求编译器知道每个栈帧里哪些位置是指针,同时还要保证运行在栈上的代码可被安全地重定位。
  3. 跨协程的引用需要写屏障。如果 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 }
    })
}
  1. 帧膨胀。一个协程帧可能包含几十个变量,如果其中还嵌套着其他 Future(await 一个 async fn 会把内层帧内联进外层帧),帧大小会层层累加。控制手段是及时 Box::pin 切断内联,或者用 -Zprint-type-sizes(Rust nightly)观察每个 Future 的实际大小。
  2. 递归 async。如上例,async fn 递归会让类型大小无限展开,必须引入 Box 或 trait object 打断递归。
  3. 取消与析构。协程被取消时,帧会被 drop,此时挂起点上的资源(文件句柄、锁、事务)需要正确释放。Rust 靠 RAII 保证,但 select! 这类会取消分支的宏要求取消安全的代码;C++20 则需要在 unhandled_exception 与析构里显式处理。
  4. 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,都能一眼看穿它在运行时到底长什么样。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. 浮点语义与快速数学优化
  2. 查询式编译器与增量类型检查:Salsa 架构
  3. Sanitizer 与编译期安全加固:ASan、TSan 与 CFI