i007.cc

i007.cc

优先队列-降维打击

05.价值资料

怎样理解有栈协程和无栈协程的区别?

这是理解协程的最核心概念,很多人用了很久协程都没真正搞清楚这个区别。


先从”栈”是什么说起

每次函数调用,CPU 都会在调用栈上压入一个栈帧,里面存着:

┌─────────────────────────────────┐  ← 栈顶(高地址)
│  main() 的栈帧                  │
│    local: argc, argv            │
├─────────────────────────────────┤
│  foo() 的栈帧                   │
│    local: x = 42                │
│    return address → main+0x1c   │
├─────────────────────────────────┤
│  bar() 的栈帧                   │  ← 当前执行位置
│    local: y = 7                 │
│    return address → foo+0x08    │
└─────────────────────────────────┘  ← 栈底(低地址)

 

函数返回时,弹出栈帧,执行权回到调用者。这是 CPU 和 OS 天然支持的机制。

协程要暂停的问题就在这里:当你想在 bar() 执行到一半时暂停,整个调用链 main → foo → bar 的状态都在栈上,你怎么把它保存起来?


有栈协程:给每个协程一个独立的栈

最直接的解法:每个协程有自己的完整调用栈,就像线程一样,只是切换由程序员控制而非操作系统。

线程的栈                协程A的栈              协程B的栈
┌──────────┐           ┌──────────┐           ┌──────────┐
│ main()   │           │ coroutineA() │        │ coroutineB() │
│          │           │   foo()  │           │   baz()  │
│          │           │     bar()│  ← 暂停点  │          │
└──────────┘           └──────────┘           └──────────┘
  OS 管理               程序自己分配的内存(通常 128KB~8MB)

 

切换时,把当前 CPU 寄存器(rsp、rbp、rip 等)保存到协程控制块,加载另一个协程的寄存器,CPU 就跳到另一个协程上次暂停的位置继续执行。

这个切换本质上和线程切换完全一样,只是不经过内核,所以叫用户态线程

cpp
// Boost.Coroutine2 / ucontext —— 有栈协程示例
boost::coroutines2::coroutine<int>::pull_type source(
    [](boost::coroutines2::coroutine<int>::push_type& sink) {
        int x = 0;
        while (true) {
            expensive_function();  // ← 可以在任意深度暂停
                foo();
                    bar();
                        sink(x++);  // 在调用栈深处暂停,完全没问题
        }
    }
);

 

有栈协程可以在任意函数调用深度暂停,因为整个调用链的状态都在它自己的栈上。


无栈协程:只保存”暂停点的局部变量”

无栈协程换了个思路:不保存整个调用栈,只保存恢复执行所需的最少状态。

编译器在编译期就分析出每个 co_await 点上哪些局部变量还活着,把它们提取出来,存到堆上的一个协程帧(状态机对象)里。

cpp
// 这个协程函数...
Task<int> compute() {
    int x = co_await fetch_a();   // 暂停点 1
    int y = co_await fetch_b(x);  // 暂停点 2
    co_return x + y;
}

// 编译器把它变成类似这样的结构:
struct compute_frame {
    int state;   // 当前在哪个暂停点
    int x;       // 暂停点1之后需要保留
    int y;       // 暂停点2之后需要保留
    // fetch_a() 和 fetch_b() 的 awaiter 对象
};

// 恢复执行时,根据 state 跳到对应位置:
void resume(compute_frame* f) {
    switch (f->state) {
        case 0: goto label_0;
        case 1: goto label_1;
        case 2: goto label_2;
    }
    label_0:
        // 启动 fetch_a,注册回调,暂停
        f->state = 1; return;
    label_1:
        f->x = get_result_of_fetch_a();
        // 启动 fetch_b,注册回调,暂停
        f->state = 2; return;
    label_2:
        f->y = get_result_of_fetch_b();
        return f->x + f->y;
}

 

这就是无栈协程的本质:一个编译器生成的状态机,加上一块存局部变量的堆内存。


两者最关键的区别:暂停点在哪

这是理解两者差异的核心:

有栈协程:可以在任意位置暂停(包括普通函数调用的深处)

Task stackful_example() {
    normal_function_a() {      // 普通函数,不是协程
        normal_function_b() {  // 普通函数,不是协程
            yield();           // ← 在这里暂停,完全没问题
        }
    }
}

─────────────────────────────────────────────────────

无栈协程:只能在协程函数本身的 co_await 处暂停

Task stackless_example() {
    co_await something();      // ← 只能在这里暂停
    normal_function_a();       // 这里面没法暂停
}

 

这个限制意味着:无栈协程的”暂停传染性”——如果一个函数想要暂停,它必须是协程,调用它的函数也必须是协程,一路向上。这就是 async/await 必须层层传染的根本原因。


内存布局对比

有栈协程(运行时):
┌────────────────────────────────────────────────────┐
│  协程A的独立栈(预分配,通常 128KB)                  │
│  ┌──────────────────────────────────────────────┐  │
│  │ coroutine_entry()                            │  │
│  │   └─ foo()                                   │  │
│  │        └─ bar()  ← 当前暂停在这里              │  │
│  └──────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────┘
内存:固定分配,无论用多少都占满(128KB)
切换:保存/恢复约15个寄存器(rsp, rbp, rip...)

─────────────────────────────────────────────────────

无栈协程(运行时):
┌──────────────────────────────────┐
│  协程帧(堆上,按需分配)          │
│  state: 2                        │
│  x: 42                           │  ← 只存活跃的局部变量
│  y: (未初始化)                    │
│  awaiter: {...}                   │
└──────────────────────────────────┘
内存:只占实际需要的空间(几十到几百字节)
切换:只需跳转到对应的 state,无需保存寄存器

 


用一个具体场景感受差距

假设你有 100 万个并发连接,每个连接需要一个协程:

有栈协程:
  100万 × 128KB(最小栈)= 128GB 内存
  即使协程什么都不干,栈空间也预先占用
  → 实际上限约 10 万个协程(受机器内存限制)

无栈协程:
  100万 × 200B(典型协程帧)= 200MB 内存
  Go 的 goroutine 虽然有初始 2KB 栈但会动态增长
  → 百万协程完全可行(Erlang/Elixir 的日常操作)

 

这就是为什么 Go 能开几百万 goroutine,而 Boost.Coroutine 很少有人开超过几万个。


栈的”可增长”是 Go 的特殊方案

Go 的 goroutine 是有栈协程,但做了一个特殊优化:分段栈 / 连续栈

初始只分配 2KB,当栈快满时自动扩容(分配更大的栈,把旧内容复制过去),最大可以长到 1GB。这样既有有栈协程”任意深度暂停”的灵活性,又缓解了内存浪费问题。代价是栈增长时有一次内存拷贝,以及指针失效问题(Go 的 GC 帮你处理了)。

Go goroutine 栈增长:
初始 2KB → 触发增长 → 4KB → ... → 按需增长到上限

 

C++ 和 Rust 没有 GC,实现可增长栈代价极高(指针会失效),所以选择了无栈协程。


两者适用场景

选有栈协程(goroutine、Boost.Coroutine、libco):

  • 需要把同步代码无侵入地改成异步(不改函数签名)
  • 系统调用需要拦截(libco 拦截了 read()/write(),普通代码无感知地变成异步)
  • 快速开发,不想让 async/await 传染整个代码库
  • 协程数量不超过几十万

选无栈协程(C++20、Rust async、Python asyncio):

  • 需要百万级协程并发
  • 对内存极度敏感(嵌入式)
  • 编译器能帮你做更激进的优化(协程帧可能被省略,分配在栈上)
  • 已经接受了 async/await 的传染性

一句话总结

有栈协程是带自己行李的旅行者,随时能停下来,但行李(128KB 栈)无论轻重都要带着;无栈协程是只存必要物品的旅行者,行李极轻(几百字节协程帧),但只能在指定地点(co_await)休息,不能在走廊里突然停下来。

发表回复