“goroutine 切换比线程切换快”——每个 Go 开发者都听过这句话,但很少有人能说清楚:goroutine 切换时 CPU 到底做了什么?哪些寄存器需要保存?栈是怎么切换的?为什么比线程切换快?
本文深入 runtime/asm_amd64.s 的汇编代码,逐指令解析 goroutine 的上下文切换过程。
本文要点
- 线程切换 vs goroutine 切换的本质区别
- goroutine 的上下文:哪些状态需要保存
- gogo:切换到目标 goroutine 的汇编实现
- 栈切换的底层机制
- mcall:从 g 栈切换到 g0 栈
- goroutine 切换的性能实测
- 与操作系统线程切换的对比
线程切换 vs goroutine 切换
本质区别
| 操作 | 线程切换(OS) | goroutine 切换(Go) |
|---|---|---|
| 触发方式 | 时钟中断/系统调用 | runtime 主动调用 |
| 特权级切换 | 用户态 → 内核态 → 用户态 | 始终在用户态 |
| 保存的寄存器 | 所有通用寄存器 + 浮点 + SIMD | SP + PC + DX + BX(约 5 个) |
| 栈切换 | 切换内核栈 | 切换用户栈 |
| TLB 影响 | 可能刷新 TLB | 无影响 |
| 耗时 | ~1-10μs | ~100-200ns |
goroutine 的上下文
goroutine 的上下文(Context)是指切换时需要保存和恢复的状态:
g 结构体中的调度相关字段
// src/runtime/runtime2.go (简化版)type g struct { stack stack // 栈的边界 [lo, hi] sched gobuf // 调度上下文(保存的寄存器) goid int64 // goroutine ID m *m // 当前绑定的 M ...}
type gobuf struct { sp uintptr // 保存的栈指针 pc uintptr // 保存的程序计数器 g guintptr // 指向 g 的指针 ret uintptr // 返回值 bp uintptr // 保存的基址指针(帧指针)}需要保存的寄存器
goroutine 切换只需保存极少量寄存器:
| 寄存器 | 作用 | 必须保存? |
|---|---|---|
| SP (RSP) | 栈指针 | 必须 |
| PC (RIP) | 程序计数器 | 必须 |
| BP (RBP) | 帧指针 | 通常 |
| DX (RDX) | 临时值 | Go 约定 |
| BX (RBX) | 临时值 | Go 约定 |
| AX-CX, SI, DI | 通用寄存器 | 调用者保存 |
| X0-X15 | SSE/AVX 寄存器 | 调用者保存 |
Go 的调用约定规定:AX、CX、SI、DI 是调用者保存(caller-saved),所以切换时不需要保存。只有 SP、PC、BP、DX、BX 需要保存到 gobuf。
gogo:切换到目标 goroutine
gogo 是 goroutine 切换的核心汇编函数,它从 gobuf 恢复寄存器并跳转到目标 goroutine:
TEXT runtime·gogo(SB), NOSPLIT, $0-8 MOVQ buf+0(FP), BX // BX = &gobuf MOVQ gobuf_g(BX), DX // DX = gobuf.g(目标 goroutine) MOVQ 0(DX), CX // 确保 g 不为 nil(检查) get_tls(CX) // 获取 TLS(线程本地存储) MOVQ DX, g(CX) // TLS = DX(设置当前 g) MOVQ gobuf_sp(BX), SP // SP = gobuf.sp(恢复栈指针) MOVQ gobuf_ret(BX), AX // AX = gobuf.ret(恢复返回值) MOVQ gobuf_bp(BX), DI // DI = gobuf.bp(恢复帧指针) MOVQ DI, (SP) // 将 bp 存到栈顶 MOVQ gobuf_pc(BX), DX // DX = gobuf.pc(目标 PC) JMP DX // 跳转到目标 PC关键步骤解析
最关键的两步:
MOVQ gobuf_sp(BX), SP:切换栈指针——这是”上下文切换”的核心JMP DX:跳转到目标 goroutine 的执行位置——不是 CALL,是 JMP
栈切换的底层机制
goroutine 的栈
每个 goroutine 有自己独立的栈,初始大小为 2KB(可增长):
goroutine A 的栈(高地址 → 低地址):┌──────────────────────┐ ← stack.hi│ frame 3 ││ frame 2 ││ frame 1 │ ← SP_A└──────────────────────┘ ← stack.lo
goroutine B 的栈:┌──────────────────────┐ ← stack.hi│ frame 2 ││ frame 1 │ ← SP_B└──────────────────────┘ ← stack.lo切换过程
切换前(运行 goroutine A): RSP = SP_A(指向 A 的栈顶) RIP = A 的当前指令
gogo(gobuf_B): RSP = SP_B(切换到 B 的栈顶) RIP = PC_B(跳转到 B 的执行位置)
切换后(运行 goroutine B): RSP = SP_B RIP = B 的当前指令就这么简单——修改 RSP 和 RIP,CPU 就自动在新的栈上运行了。
mcall:从 g 栈切换到 g0 栈
mcall 是另一个关键的切换函数,用于从用户 goroutine 切换到 g0(M 的系统栈):
TEXT runtime·mcall(SB), NOSPLIT, $0-8 MOVQ fn+0(FP), DX // DX = 要在 g0 上调用的函数
get_tls(CX) MOVQ g(CX), BX // BX = 当前 g MOVQ g_m(BX), BX // BX = 当前 m
// 保存当前 g 的调度上下文 MOVQ m_g0(BX), SI // SI = g0 MOVQ (SP), DI // DI = 调用者的 PC(返回地址) MOVQ DI, (g_sched+gobuf_pc)(BX) // 保存 PC MOVQ SP, (g_sched+gobuf_sp)(BX) // 保存 SP MOVQ $0, (g_sched+gobuf_bp)(BX) // 清除 BP
// 切换到 g0 的栈 MOVQ (g_sched+gobuf_sp)(SI), SP // SP = g0.sched.sp
// 设置当前 g = g0 MOVQ SI, g(CX)
// 调用目标函数 CALL (DX)mcall 的用途
| 场景 | 说明 |
|---|---|
gopark | 挂起 goroutine,需要在 g0 栈上操作 |
entersyscall | 系统调用前,在 g0 栈上保存状态 |
newstack | 栈增长,需要在 g0 栈上分配新栈 |
gcStart | GC 启动,需要在 g0 栈上执行 |
goroutine 切换的性能实测
基准测试
func BenchmarkGoroutineSwitch(b *testing.B) { var wg sync.WaitGroup ch := make(chan struct{})
// 启动一个 goroutine 做乒乓切换 go func() { for i := 0; i < b.N; i++ { <-ch // 阻塞 ch <- struct{}{} // 唤醒对方 } }()
b.ResetTimer() for i := 0; i < b.N; i++ { ch <- struct{}{} // 唤醒对方 <-ch // 阻塞 }}实测结果
| 切换类型 | 耗时 | 说明 |
|---|---|---|
| goroutine(channel) | ~170ns | 包含 channel 操作 |
| goroutine(gopark/goready) | ~50ns | 纯调度切换 |
| OS 线程(pthread) | ~1-2μs | 内核参与 |
| OS 线程(futex) | ~3-5μs | 包含内核切换 |
goroutine 切换比线程切换快 10-100 倍。
常见问题 FAQ
Q1:goroutine 切换为什么不经过内核?
因为 goroutine 是用户态调度——所有调度决策都在 runtime 中做出,不需要内核参与。内核只知道操作系统线程(M),不知道 goroutine 的存在。
Q2:goroutine 切换保存的寄存器为什么这么少?
Go 的调用约定将大部分寄存器标记为”调用者保存”(caller-saved),意味着调用者(编译器生成的代码)已经在栈上保存了这些寄存器。切换时只需要保存调度器关心的寄存器(SP、PC、BP)。
Q3:goroutine 切换会刷新 TLB 吗?
不会。TLB 是虚拟地址到物理地址的缓存,goroutine 切换不改变页表(所有 goroutine 共享同一个地址空间),所以 TLB 不受影响。线程切换可能触发 TLB 刷新(如果切换到不同进程)。
Q4:为什么 mcall 要切换到 g0 栈?
g0 栈是 M 的系统栈,大小固定(64KB),不会增长。在 g0 栈上执行可以安全地进行栈增长、GC 等操作,不用担心栈溢出。用户 goroutine 的栈可能很小(2KB),在这些操作期间可能不够用。
Q5:goroutine 切换的开销可以忽略吗?
对于大多数应用,可以忽略。但在极端场景下(每秒百万次 channel 操作),切换开销会累积到可观测的程度。此时应考虑减少 channel 操作次数(批量处理)。
小结
- goroutine 切换是纯用户态操作,不经过内核,不涉及特权级切换
- 只需保存 5 个寄存器(SP、PC、BP、DX、BX),远少于线程切换
- gogo 是核心:恢复 SP → 恢复 PC → JMP,三步完成切换
- 栈切换是关键:修改 RSP 就切换到了目标 goroutine 的栈
- mcall 切换到 g0 栈:用于 runtime 内部操作(GC、栈增长等)
- goroutine 切换约 50-170ns,比线程切换快 10-100 倍
参考资料
- Go Runtime Source: asm_amd64.s — gogo/mcall 汇编实现
- Go Runtime Source: runtime2.go — g/gobuf 结构体
- Go Runtime Source: proc.go — schedule/execute
- Analysis of the Go runtime scheduler — 学术分析
- Go 1.14 asynchronous preemption — 基于信号的抢占
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






