怎样理解有栈协程和无栈协程的区别?
这是理解协程的最核心概念,很多人用了很久协程都没真正搞清楚这个区别。
先从”栈”是什么说起
每次函数调用,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 就跳到另一个协程上次暂停的位置继续执行。
这个切换本质上和线程切换完全一样,只是不经过内核,所以叫用户态线程。
// 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 点上哪些局部变量还活着,把它们提取出来,存到堆上的一个协程帧(状态机对象)里。
// 这个协程函数...
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)休息,不能在走廊里突然停下来。
