专栏文章
专栏文章
协程系列
1. 协程系列 #01:从 C10K 到协程:并发模型的演化史 2. 协程系列 #02:协程的实现机制:有栈与无栈、调度模型深度解析 3. 协程系列 #03:各语言的协程权衡:Go、Python、Kotlin、Java VT、C++20 横向对比

协程系列 #02:协程的实现机制:有栈与无栈、调度模型深度解析

发布于 2026-05-25 12:28 👁 27 次阅读
#协程#并发#源码解析#操作系统

协程的"暂停与恢复"在 CPU 和内存层面并不神秘:本质是保存/恢复执行上下文。但有栈和无栈走的是两条根本不同的路——前者在运行时管理独立的调用栈,后者让编译器把函数变成状态机。读完本文,你将理解这两条路各自的代价与边界,以及调度模型如何决定并发与并行的能力上限。


目录

章节 说明
执行上下文是什么 协程暂停/恢复的物理基础
有栈协程 独立调用栈、汇编级切换、栈增长策略
无栈协程 状态机变换、send() 驱动、协程帧
调度模型 1:N、M:N、工作窃取、调度时机
有栈 vs 无栈的根本取舍 六维度对比表
与线程对比的数字 创建/切换/内存的量级差异

执行上下文是什么

要运行一个函数,CPU 至少需要:

组成 作用
PC(程序计数器 / rip 下一条要执行的指令地址
SP(栈指针 / rsp 当前调用栈顶位置
通用寄存器raxrbx…) 局部变量、临时计算结果
调用栈帧链 每层函数的局部变量、返回地址

普通函数调用是严格的 LIFO(后进先出):调用方把控制权交给被调用方,被调用方返回后控制权回到调用方,栈帧随即销毁。这个约束意味着你无法在一个函数执行到一半时"冻结"它,然后去运行另一个函数,再回来继续。

协程打破了这个限制

这和操作系统的进程/线程上下文切换是同一个思路,区别在于:OS 上下文切换需要进内核(syscall),要保存/恢复更多状态(浮点寄存器、信号掩码、TLB……);协程切换完全在用户态完成,开销低 1-2 个数量级。


有栈协程(Stackful Coroutine)

内存模型

每个协程拥有独立的调用栈,与普通线程的栈结构相同,只是尺寸更小、由运行时管理。

stackful layout

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。

stack growth

上下文切换(x86_64 汇编级)

x86_64 调用约定规定,callee-saved 寄存器(被调用方负责保存)有: rbprbxr12r13r14r15,加上 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 函数变换成一个状态机对象。

stackful vs stackless

变换示例(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)   # 协程结束,返回最终结果

关键洞察:跨越挂起点的局部变量(dataresult必须提升到 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

M:N 模型(混合调度)

M 个协程映射到 N 个 OS 线程,既有并发也有并行。

gmp schedule

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 的任务(见上图右侧箭头):

调度时机

触发条件 有栈协程(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.hsigset_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

数字背后的原因

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)

暂无评论,来留下第一条吧。

发表评论