协程的栈——栈到底在哪?

堆上!按需增长 vs OS 线程的固定 1MB——挂起是拆卸调用链,恢复是重组

栈 vs 堆 续体链表 拆卸/重组 内存对比 StackOverflow

目录导航(点击跳转)

一、先搞懂 OS 线程的栈

1 程序运行需要栈来干这些事

函数调用时,栈用来存放局部变量返回地址

void a() {
    int x = 1;                    // ← 局部变量 x → 压栈
    b();                          // ← 返回地址 → 压栈
}                                 // ← a 结束,x 和返回地址弹栈

void b() {
    int y = 2;                    // ← y 压栈
    c();
}

void c() {
    int z = 3;                    // ← z 压栈
}
2 OS 线程栈的物理结构(向下生长,固定 1MB)
高地址(栈底)
a 帧 x = 1 返回地址 → main
b 帧 y = 2 返回地址 → a
c 帧(栈顶) z = 3 返回地址 → b
← SP 栈指针指向当前栈顶 栈向下生长 ↓
···················· 剩余 ~1MB 空闲(已分配,未使用) ····················
低地址(栈顶方向)
栈向下生长

三个问题:

  • 函数调用多深?OS 不知道 → 预分配 1MB(粗暴但保险)
  • 递归太深 → 栈溢出(StackOverflow),因为 1MB 是固定上限
  • 线程一创建栈就分配了,不管你用不用

二、协程的栈:堆上的续体对象链表

1 协程没有「一大块预分配的栈」

协程的局部变量是存在堆上的状态机对象的字段里。调用链的每一层,都是一个独立对象。

suspend fun a() {
    val x = 1            // 字段在 a 的 Continuation 对象里
    b()                  // 调用 suspend b
}

suspend fun b() {
    val y = 2            // 字段在 b 的 Continuation 对象里
    c()
}

suspend fun c() {
    val z = 3            // 字段在 c 的 Continuation 对象里
}
2 OS 线程栈 vs 协程「栈」:并排对比
OS 线程栈(固定大块)
高地址(栈底)
a 帧 x = 1 返回 → main
b 帧 y = 2 返回 → a
c 帧 z = 3 返回 → b
← SP(栈指针)
······· ~1MB 空闲(已分配未使用) ·······
低地址(栈顶方向)↓
固定 1MB,不管用不用
天塌下来 1MB 也在那
协程的栈(堆上的链表)
a 的续体对象
x = 1
指向 b →
b 的续体对象
y = 2
指向 c →
c 的续体对象
z = 3
指向 null
用了 3 个小对象,总共几百字节
用多少占多少

三、挂起时发生了什么?——「拆卸」调用链

1 协程调用 delay(1000) 挂起时
▶ 挂起前:线程正在跑协程,调用链完整
OS 线程
a → b → c
正在执行
续体链表
a → b → c
c 里执行 delay(1000)
发现要挂起
返回 SUSPENDED
label=1
挂起后:线程走了,调用链在堆上
OS 线程
跑协程 D 去了
已分离
续体链表
a → b → c
c 挂起中...
delay(1000) 等待中
label=1
停在这里

线程跟协程的调用链完全解绑了。线程去跑 D,A→B→C 的完整上下文全在堆上睡着。

2 拆卸的过程:一步步看
线程 协程调用链
1 线程正执行 c() 里的代码
2 c() 调用 delay(1000) → 发现需要等 → 返回 COROUTINE_SUSPENDED
c 的续体记录 label=1,表示"下次从 delay 后面继续"
3 b() 收到 COROUTINE_SUSPENDED → 原样返回
4 a() 收到 COROUTINE_SUSPENDED → 原样返回
整条链一路 unwind,每个续体都保留在堆上,label 各不同
5 线程回到 Runtime 的事件循环,取下一个就绪协程

挂起不是"停在某行代码上",而是整条调用链一路 return,把状态留在堆上的续体对象里

四、恢复时:「重组」调用链

1 1 秒到了,Runtime 要恢复协程
定时器到期
拿出协程对象的根引用
找到 a 的续体
a → b → c
整条链都在堆上
从 c 的 label=1
继续执行

恢复不需要重建栈——东西一直在堆上,只是没有线程理它。就像一个暂停的游戏存档,读档时所有状态都在,直接从上次停的地方继续。

2 恢复的详细流程
Runtime 续体链表 线程
1 从定时器队列取出协程对象
2 找到 a 的续体 → 放入就绪队列
3 某线程拿到这个协程
4 调用 a.invokeSuspend() → a 调用 b.invokeSuspend() → b 调用 c.invokeSuspend()
这是一个普通的函数调用链——a 调 b,b 调 c,层层深入
5 c 从 label=1 继续执行(delay 之后的那行代码)
就像一个普通的函数调用,只不过变量在堆上传下来的

五、开销对比:1GB vs 几百 KB

1 1000 线程 vs 1000 协程
1000 线程

1000 × 1MB 栈 = 1GB 栈内存

1000 个线程 = 不可能(现实最多几千)

1MB
1MB
1MB
1MB
...
× 1000 = 1GB
1000 协程

每个按需生长,总共可能就几百 KB

1000 个协程 = 轻松

× 1000 = ~几百KB

六、为什么协程没有 StackOverflow?

1 对比
OS 线程 协程
递归 10000 层 1MB 栈 → 不够 → StackOverflow 堆 → 10000 个续体对象 → 不够?
存储位置 虚拟地址空间的栈区(固定上限) 堆(可以用到好几个 GB)
失败模式 StackOverflowError OutOfMemoryError
实际业务 递归几百层就可能崩 实际业务中很少用到 10000 层调用

简单说:协程的调用链是堆上的链表,不是连续的内存块。堆可以用到好几个 GB,而 OS 线程栈只有 1MB。所以协程不会有 StackOverflow——只会 OutOfMemory,但在实际业务中几乎不可能到那个量级。

七、核心思想总结

第四章核心要点

一句话总结第四章

OS 线程栈 = 一块连续、固定 1MB 的内存,在虚拟地址空间里,预先分配,不能伸缩。协程的"栈" = 堆上的续体对象链表,每个调用层一个对象,用多少占多少,随时挂起随时恢复。挂起 = 线程离开这个链表,但链表完整留在堆上。恢复 = 线程重新接管这个链表,从上次停的地方继续。