mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1434 字
4 分钟
goroutine 上下文切换详解:CPU 到底做了什么?
2023-04-01

“goroutine 切换比线程切换快”——每个 Go 开发者都听过这句话,但很少有人能说清楚:goroutine 切换时 CPU 到底做了什么?哪些寄存器需要保存?栈是怎么切换的?为什么比线程切换快?

本文深入 runtime/asm_amd64.s 的汇编代码,逐指令解析 goroutine 的上下文切换过程。

本文要点#

  • 线程切换 vs goroutine 切换的本质区别
  • goroutine 的上下文:哪些状态需要保存
  • gogo:切换到目标 goroutine 的汇编实现
  • 栈切换的底层机制
  • mcall:从 g 栈切换到 g0 栈
  • goroutine 切换的性能实测
  • 与操作系统线程切换的对比

线程切换 vs goroutine 切换#

本质区别#

操作线程切换(OS)goroutine 切换(Go)
触发方式时钟中断/系统调用runtime 主动调用
特权级切换用户态 → 内核态 → 用户态始终在用户态
保存的寄存器所有通用寄存器 + 浮点 + SIMDSP + PC + DX + BX(约 5 个)
栈切换切换内核栈切换用户栈
TLB 影响可能刷新 TLB无影响
耗时~1-10μs~100-200ns
graph LR subgraph "线程切换" T1["用户态"] --> |"中断/系统调用"| T2["内核态"] T2 --> |"保存所有寄存器<br/>切换内核栈<br/>调度"| T3["内核态"] T3 --> |"恢复寄存器<br/>返回用户态"| T4["用户态"] end subgraph "goroutine 切换" G1["用户态"] --> |"保存 SP/PC<br/>切换栈<br/>跳转"| G2["用户态"] end style G2 fill:#4CAF50,color:#fff style T4 fill:#FF9800,color:#fff

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-X15SSE/AVX 寄存器调用者保存

Go 的调用约定规定:AX、CX、SI、DI 是调用者保存(caller-saved),所以切换时不需要保存。只有 SP、PC、BP、DX、BX 需要保存到 gobuf

gogo:切换到目标 goroutine#

gogo 是 goroutine 切换的核心汇编函数,它从 gobuf 恢复寄存器并跳转到目标 goroutine:

src/runtime/asm_amd64.s
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

关键步骤解析#

flowchart TD A["1. 从 gobuf 加载 g 指针"] --> B["2. 设置 TLS = g<br/>(告诉 M 当前运行哪个 g)"] B --> C["3. SP = gobuf.sp<br/>(切换栈!)"] C --> D["4. 恢复 BP(帧指针)"] D --> E["5. DX = gobuf.pc<br/>(加载目标 PC)"] E --> F["6. JMP DX<br/>(跳转到目标 goroutine!)"] style C fill:#F44336,color:#fff style F fill:#4CAF50,color:#fff

最关键的两步

  1. MOVQ gobuf_sp(BX), SP:切换栈指针——这是”上下文切换”的核心
  2. 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 的系统栈):

src/runtime/asm_amd64.s
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 栈上分配新栈
gcStartGC 启动,需要在 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 操作次数(批量处理)。

小结#

  1. goroutine 切换是纯用户态操作,不经过内核,不涉及特权级切换
  2. 只需保存 5 个寄存器(SP、PC、BP、DX、BX),远少于线程切换
  3. gogo 是核心:恢复 SP → 恢复 PC → JMP,三步完成切换
  4. 栈切换是关键:修改 RSP 就切换到了目标 goroutine 的栈
  5. mcall 切换到 g0 栈:用于 runtime 内部操作(GC、栈增长等)
  6. goroutine 切换约 50-170ns,比线程切换快 10-100 倍

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

goroutine 上下文切换详解:CPU 到底做了什么?
https://blog.souloss.com/posts/golang/go-goroutine-switch/
作者
Souloss
发布于
2023-04-01
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时