协程的"暂停与恢复"在 CPU 和内存层面并不神秘:本质是保存/恢复执行上下文。但有栈和无栈走的是两条根本不同的路——前者在运行时管理独立的调用栈,后者让编译器把函数变成状态机。读完本文,你将理解这两条路各自的代价与边界,以及调度模型如何决定并发与并行的能力上限。
目录
| 章节 | 说明 |
|---|---|
| 执行上下文是什么 | 协程暂停/恢复的物理基础 |
| 有栈协程 | 独立调用栈、汇编级切换、栈增长策略 |
| 无栈协程 | 状态机变换、send() 驱动、协程帧 |
| 调度模型 | 1:N、M:N、工作窃取、调度时机 |
| 有栈 vs 无栈的根本取舍 | 六维度对比表 |
| 与线程对比的数字 | 创建/切换/内存的量级差异 |
执行上下文是什么
要运行一个函数,CPU 至少需要:
| 组成 | 作用 |
|---|---|
PC(程序计数器 / rip) |
下一条要执行的指令地址 |
SP(栈指针 / rsp) |
当前调用栈顶位置 |
通用寄存器(rax、rbx…) |
局部变量、临时计算结果 |
| 调用栈帧链 | 每层函数的局部变量、返回地址 |
普通函数调用是严格的 LIFO(后进先出):调用方把控制权交给被调用方,被调用方返回后控制权回到调用方,栈帧随即销毁。这个约束意味着你无法在一个函数执行到一半时"冻结"它,然后去运行另一个函数,再回来继续。
协程打破了这个限制:
- 暂停一个协程 = 把当前的 PC、SP 以及所有 callee-saved 寄存器保存到协程控制块(coroutine control block)
- 恢复一个协程 = 把该控制块里保存的寄存器值加载回 CPU,跳转到保存的 PC 继续执行
这和操作系统的进程/线程上下文切换是同一个思路,区别在于:OS 上下文切换需要进内核(syscall),要保存/恢复更多状态(浮点寄存器、信号掩码、TLB……);协程切换完全在用户态完成,开销低 1-2 个数量级。
有栈协程(Stackful Coroutine)
内存模型
每个协程拥有独立的调用栈,与普通线程的栈结构相同,只是尺寸更小、由运行时管理。
Go goroutine 的栈参数:初始 2 KB,运行时动态增长,理论上限 1 GB(可配置)。
关键特性:可以在任意调用深度挂起。yield 可以发生在 funcA → funcB → funcC 的最深处,整条调用链都被完整保存。
栈增长策略:从分段栈到连续栈
Go 早期(1.3 之前)使用分段栈(Segmented Stack):栈空间用完时分配一段新的内存块,通过链表串联。
缺陷:Hot Split 问题。若某个热点函数恰好在栈边界处被反复调用,每次调用都触发分配/释放新栈段,性能急剧下降。
Go 1.4 改为连续栈(Copying Stack):栈空间不足时,分配一块 2 倍大小的新内存,把旧栈内容全部复制过去,更新所有指向旧栈的指针,释放旧栈。代价是复制时间与栈大小成正比,但消灭了 Hot Split。
上下文切换(x86_64 汇编级)
x86_64 调用约定规定,callee-saved 寄存器(被调用方负责保存)有:
rbp、rbx、r12、r13、r14、r15,加上 rsp(栈指针)和 rip(返回地址)。
最简单的协程切换伪汇编:
; switch_coroutine(from_ctx, to_ctx)
; 约定:rdi = from_ctx 地址,rsi = to_ctx 地址
; ① 保存当前协程(from)的寄存器
mov [rdi + 0 ], rbp
mov [rdi + 8 ], rbx
mov [rdi + 16], r12
mov [rdi + 24], r13
mov [rdi + 32], r14
mov [rdi + 40], r15
mov [rdi + 48], rsp
lea rax, [rip + return_label] ; 保存返回地址(即下次恢复时继续的位置)
mov [rdi + 56], rax
; ② 恢复目标协程(to)的寄存器
mov rbp, [rsi + 0 ]
mov rbx, [rsi + 8 ]
mov r12, [rsi + 16]
mov r13, [rsi + 24]
mov r14, [rsi + 32]
mov r15, [rsi + 40]
mov rsp, [rsi + 48]
jmp [rsi + 56] ; 跳转到 to 协程保存的 PC
return_label:
; 当 from 协程被恢复时,从这里继续
ret
整个切换只有约 10 条指令,无 syscall,无 TLB flush,比线程切换快 10~100 倍。
典型实现
| 实现 | 语言 | 说明 |
|---|---|---|
| Go goroutine | Go | 运行时管理,初始 2 KB 栈,GMP 调度 |
| Lua coroutine | Lua | 语言内置,轻量级,手动 yield/resume |
| libco | C | 腾讯微信后台使用,基于 ucontext/汇编 |
无栈协程(Stackless Coroutine)
核心思路:状态机变换
无栈协程不分配独立调用栈。挂起点之间的局部变量保存在 heap 上的协程帧(coroutine frame)里。编译器把每个 async 函数变换成一个状态机对象。
变换示例(Python)
原始代码:
async def fetch():
data = await read_socket()
result = await process(data)
return result
编译器将其变换为等价的状态机(概念伪代码):
class fetch_coroutine:
"""编译器生成的状态机,对应 async def fetch()"""
def __init__(self):
self.state = 0 # 当前状态
self.data = None # 跨挂起点的局部变量(保存在协程帧里)
self.result = None
def send(self, value):
"""每次调用推进一步,返回下一个可等待对象;函数结束时抛 StopIteration"""
if self.state == 0:
# 第一次进入:挂起在 read_socket()
self._awaitable = read_socket()
self.state = 1
return self._awaitable # 把控制权还给事件循环
elif self.state == 1:
# read_socket() 完成,value 是其结果
self.data = value
# 挂起在 process(data)
self._awaitable = process(self.data)
self.state = 2
return self._awaitable # 再次让出控制权
elif self.state == 2:
# process() 完成,value 是其结果
self.result = value
raise StopIteration(self.result) # 协程结束,返回最终结果
关键洞察:跨越挂起点的局部变量(
data、result)必须提升到 heap 上的协程帧,否则挂起时栈帧销毁就丢失了;而只在单个状态内使用的临时变量则可以用普通局部变量。
send() 驱动机制(Python __await__ 协议)
事件循环推进协程的过程:
sequenceDiagram
participant EL as 事件循环
participant CO as 协程
participant FU as Future/IO
EL->>CO: coroutine.send(None)(首次启动)
CO->>CO: 运行到第一个 await
CO-->>EL: 返回 awaitable(Future)
EL->>FU: 注册 IO 回调
Note over FU: IO 完成
FU-->>EL: set_result(data)
EL->>CO: coroutine.send(data)(恢复)
CO->>CO: 运行到下一个 await 或 return
CO-->>EL: 返回下一个 awaitable 或 StopIteration
典型实现
| 实现 | 语言 | 特点 |
|---|---|---|
| asyncio | Python | 单线程事件循环,async/await 语法糖 |
| async/await | JavaScript | 基于 Promise,V8 引擎状态机变换 |
| Kotlin coroutine | Kotlin | 编译器变换 + 调度器,支持 M:N |
| C++20 coroutine | C++ | 底层原语,由库(如 cppcoro)组合 |
调度模型
1:N 模型(用户级线程)
所有协程运行在单个 OS 线程上,由用户态调度器负责切换。
graph TD
M["OS 线程(单个)"]
M --> EL["事件循环 / 调度器"]
EL --> CA["协程 A(运行中)"]
EL --> CB["协程 B(等待 IO)"]
EL --> CC["协程 C(就绪,排队)"]
style CA fill:#d5e8d4,stroke:#82b366
style CB fill:#f8cecc,stroke:#b85450
style CC fill:#dae8fc,stroke:#6c8ebf
- 只有并发,没有并行:同一时刻只有一个协程在 CPU 上运行
- 适合 IO 密集型任务:等待期间调度器切换到其他协程
- 典型:Python asyncio、Node.js
- 致命缺陷:一个 CPU 密集任务(如复杂计算)会阻塞整个事件循环,其他协程完全得不到执行
M:N 模型(混合调度)
M 个协程映射到 N 个 OS 线程,既有并发也有并行。
Go GMP 模型(详细机制在后续篇章展开):
| 组件 | 全称 | 职责 |
|---|---|---|
| G | Goroutine | 用户级协程,携带栈和执行上下文 |
| M | Machine(OS Thread) | 真正在 CPU 上执行的 OS 线程 |
| P | Processor | 逻辑处理器,持有本地 goroutine 运行队列,连接 G 和 M |
运行时维护一个全局队列和每个 P 的本地队列,M 必须持有一个 P 才能运行 G。默认 P 的数量等于 CPU 核心数(GOMAXPROCS)。
Java Virtual Thread(JDK 21+)同样是 M:N:VT 映射到 Carrier Thread(OS 线程池),调度器由 ForkJoinPool 驱动。
工作窃取(Work Stealing)
当某个 P 的本地队列空了,它不会闲置,而是去偷其他 P 的任务(见上图右侧箭头):
- 从队列尾部偷(FIFO 执行,LIFO 偷),减少与 P2 本地执行的锁竞争
- 使各 P 负载趋于均衡,减少 CPU 空闲,提升整体吞吐
调度时机
| 触发条件 | 有栈协程(Go) | 无栈协程(asyncio) |
|---|---|---|
| 主动让出 | runtime.Gosched() |
await asyncio.sleep(0) |
| IO 等待 | 协程挂起,M 继续运行其他 G | 协程挂起,事件循环调度下一个 |
| 系统调用 | 运行时透明包装,M 阻塞时 P 迁移到新 M | 必须用异步版本,同步 syscall 阻塞整个线程 |
| 函数调用 | 编译器插入抢占检查点(Go 1.14+ 异步抢占) | 不适用(状态机内无隐式抢占) |
Go 1.14 引入基于信号的异步抢占(
SIGURG),即使协程处于无 IO、无函数调用的纯计算循环中也能被抢占,彻底解决了早期版本中 CPU 密集协程饿死调度器的问题。
有栈 vs 无栈的根本取舍
| 维度 | 有栈协程 | 无栈协程 |
|---|---|---|
| 挂起点灵活性 | 任意调用深度均可 yield | 只能在标注了 async 的函数内 await |
| 传染性 | 无——普通函数可以直接调用协程 | 有——async 向上感染整条调用链("function color"问题) |
| 内存开销 | 每个协程独立调用栈(Go 初始 2 KB) | 只需协程帧(仅跨挂起点的变量,通常几十到几百字节) |
| 切换性能 | 保存/恢复 callee-saved 寄存器,约 10 条指令 | 状态机跳转,本质是 switch-case,极快 |
| 实现复杂度 | 运行时需管理栈内存、处理栈增长/收缩 | 依赖编译器做变换;运行时逻辑简单 |
| 典型代表 | Go、Lua、libco | Python asyncio、JS async/await、Kotlin、C++20 |
一句话:有栈协程对程序员透明(写起来像普通函数),无栈协程对运行时透明(内存极省、编译器全包)。两者都是正确的选择,取决于语言设计目标。
与线程对比的数字
以下数据基于典型 Linux x86_64 环境,供量级参考,实际值因硬件和内核版本有差异。
| 指标 | 协程(Go goroutine) | OS 线程(Linux pthread) | 差异倍数 |
|---|---|---|---|
| 创建开销 | ~1 µs | ~50 µs | ~50x |
| 上下文切换 | ~100 ns(用户态) | ~1–10 µs(内核态) | ~10–100x |
| 初始内存 | 2 KB(动态增长) | 8 MB(Linux 默认栈) | ~4000x |
| 切换指令数 | ~10 条(callee-saved 寄存器) | 数百条(+syscall+TLB) | >10x |
上下文切换开销精确拆解
数据来源:Go runtime 源码、Intel SDM、Linux 内核源码、lmbench 实测。
保存的状态(x86_64)
| 保存项 | goroutine 切换 | 线程切换(同进程) | 大小 | 来源 |
|---|---|---|---|---|
| SP / PC / BP | ✅ | ✅ | 24 B | Go: runtime/runtime2.go:324 gobuf; Linux: arch/x86/kernel/process_64.c __switch_to() |
| callee-saved 通用寄存器(rbx r12–r15) | ✅(Go 不保存,由 ABI 约束) | ✅ | 40 B | x86_64 SysV ABI §3.2.1 |
| 函数调用上下文(ctxt / g 指针) | ✅ | — | 16 B | Go: gobuf.ctxt + gobuf.g |
| gobuf 合计 | 56 B | — | — | runtime/runtime2.go:324,7 个 uintptr |
| x87 FPU + SSE(FXSAVE legacy 区) | ❌ | ✅ | 512 B | Intel SDM Vol.1 §10.5.1,Table 13-1 |
| AVX 扩展状态(YMM 高 128 位) | ❌ | ✅(按需) | +256 B | Intel SDM Vol.1 Table 13-1,XSAVE extended region |
| AVX-512(ZMM 高位 + opmask) | ❌ | ✅(按需) | +1600 B | Intel SDM Vol.1 Table 13-1 |
信号掩码(task_struct.blocked) |
❌(同线程,不变) | ✅ | 8 B | include/linux/sched.h,sigset_t = 64 位 |
Go 运行时在 goroutine yield 点(channel 操作、
runtime.Gosched())不保存 FPU 寄存器,因为编译器保证 yield 前浮点操作已完成。线程切换由内核触发(抢占),无法做此保证,故必须用XSAVE完整保存。
各开销项是否触发
| 开销项 | goroutine 切换 | 同进程线程切换 | 跨进程切换 | 说明 |
|---|---|---|---|---|
| syscall(ring3→ring0) | ❌ | ✅ | ✅ | 线程切换必须进内核调度器 kernel/sched/core.c:__schedule() |
| 内核调度器执行 | ❌ | ✅ | ✅ | __schedule() 约数百条指令 |
| XSAVE / XRSTOR | ❌ | ✅ | ✅ | 512 B~2+ KB,arch/x86/kernel/fpu/core.c:switch_fpu_finish() |
| CR3 切换(页表切换) | ❌ | ❌ | ✅ | 同进程共享地址空间,CR3 不变 |
| TLB 全量 flush | ❌ | ❌ | ✅(无 PCID)/ ❌(有 PCID) | Linux 4.14+ 启用 PCID(x86 PCID 特性),跨进程切换也可保留 TLB 条目;Intel SDM Vol.3A §4.10.4 |
| TLB 自然淘汰(容量驱逐) | ✅(可能) | ✅(可能) | ✅ | L1 dTLB ~64 项,L2 TLB ~1500 项;两个协程/线程 working set 不同则互相驱逐,与切换机制无关 |
TLB 的精确结论:goroutine 切换和同进程线程切换都不触发显式 TLB flush;真正的 TLB flush 只发生在跨进程切换且 CPU 不支持 PCID 时。两者都会有 TLB 容量驱逐(自然淘汰),这是 working set 大小的函数,不是切换机制造成的。原先"协程不触发 TLB flush,线程会触发"的表述对同进程线程切换是不准确的。
延迟实测参考
| 场景 | 典型延迟 | 测量工具 / 来源 |
|---|---|---|
| goroutine 切换(纯切换) | ~100–200 ns | go test -bench runtime 内部 benchmark |
goroutine gogo<> 指令路径 |
~8 MOVQ + 1 JMP ≈ 3–5 ns | runtime/asm_amd64.s:410,3 GHz CPU |
| 同进程线程切换 | ~1–3 µs | lmbench lat_ctx,现代 x86_64 |
syscall 往返(getpid) |
~100–300 ns | lmbench lat_syscall;Spectre/KPTI 前后差异约 10–30% |
| 跨进程切换(无 PCID) | ~3–10 µs | lmbench lat_ctx -s 0,含 TLB flush |
数字背后的原因:
- 创建快:goroutine 只分配一小块栈内存并初始化控制块;线程需要进内核、分配 8 MB 虚拟地址空间、建立内核数据结构
- 切换快:goroutine 切换无需进内核(无 syscall),无需 XSAVE 保存 512 B+ 的浮点状态;TLB flush 不是主要差异点(同进程线程切换也不 flush TLB)
- 内存省:goroutine 2 KB 的初始栈可以在单机上同时跑百万级协程;线程 8 MB 默认栈限制并发数量通常在数千级别
1,000,000 goroutines × 2 KB ≈ 2 GB 内存(可行)
1,000,000 threads × 8 MB ≈ 8 TB 内存(不可行)
参考资料
- PEP 342 — Coroutines via Enhanced Generators
- PEP 492 — Coroutines with async and await syntax
- Dmitry Vyukov, Go Preemptive Scheduler Design
- Austin Clements, Proposal: Non-cooperative goroutine preemption(Go 1.14 异步抢占)
- Lewis Baker, C++ Coroutines: Understanding the Compiler Transform
- Kotlin Coroutines Design Document(JetBrains KEEP-76)
- POSIX
ucontext_t手册(man 3 makecontext)- 从 C10K 到协程(为什么需要协程,并发模型的历史演化)
- 各语言的协程权衡(各语言在本文框架下的具体实现选择)
- 进程与线程(OS 线程上下文切换的对比基准)
- ../../02 编程语言/03 GoLang/03 GoLang 并发编程(Go GMP 调度模型的工程实践细节)
评论 (0)
发表评论