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

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

发布于 2026-05-25 12:28 · 最后编辑于 2026-07-31 15:53 · 字数 5,525 👁 117 次阅读

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

目录

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

执行上下文是什么

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

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

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

协程打破了这个限制

  • 暂停一个协程 = 把当前的 PC、SP 以及所有 callee-saved 寄存器保存到协程控制块(coroutine control block)
  • 恢复一个协程 = 把该控制块里保存的寄存器值加载回 CPU,跳转到保存的 PC 继续执行

这和操作系统的进程/线程上下文切换是同一个思路,区别在于: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 goroutineGo运行时管理,初始 2 KB 栈,GMP 调度
Lua coroutineLua语言内置,轻量级,手动 yield/resume
libcoC腾讯微信后台使用,基于 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

典型实现

实现语言特点
asyncioPython单线程事件循环,async/await 语法糖
async/awaitJavaScript基于 Promise,V8 引擎状态机变换
Kotlin coroutineKotlin编译器变换 + 调度器,支持 M:N
C++20 coroutineC++底层原语,由库(如 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 线程,既有并发也有并行。

gmp schedule

Go GMP 模型(详细机制在后续篇章展开):

组件全称职责
GGoroutine用户级协程,携带栈和执行上下文
MMachine(OS Thread)真正在 CPU 上执行的 OS 线程
PProcessor逻辑处理器,持有本地 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、libcoPython 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 / BP24 BGo: runtime/runtime2.go:324 gobuf; Linux: arch/x86/kernel/process_64.c __switch_to()
callee-saved 通用寄存器(rbx r12–r15)✅(Go 不保存,由 ABI 约束)40 Bx86_64 SysV ABI §3.2.1
函数调用上下文(ctxt / g 指针)16 BGo: gobuf.ctxt + gobuf.g
gobuf 合计56 Bruntime/runtime2.go:324,7 个 uintptr
x87 FPU + SSE(FXSAVE legacy 区)512 BIntel SDM Vol.1 §10.5.1,Table 13-1
AVX 扩展状态(YMM 高 128 位)✅(按需)+256 BIntel SDM Vol.1 Table 13-1,XSAVE extended region
AVX-512(ZMM 高位 + opmask)✅(按需)+1600 BIntel SDM Vol.1 Table 13-1
信号掩码(task_struct.blocked❌(同线程,不变)8 Binclude/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 / XRSTOR512 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 nsgo test -bench runtime 内部 benchmark
goroutine gogo<> 指令路径~8 MOVQ + 1 JMP ≈ 3–5 nsruntime/asm_amd64.s:410,3 GHz CPU
同进程线程切换~1–3 µslmbench lat_ctx,现代 x86_64
syscall 往返(getpid~100–300 nslmbench lat_syscall;Spectre/KPTI 前后差异约 10–30%
跨进程切换(无 PCID)~3–10 µslmbench 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 内存(不可行)

参考资料

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论