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

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

本文深入 runtime/asm_amd64.s 的汇编代码,逐指令解析 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)
stackguard0 uintptr // 栈溢出检查,也用于抢占信号
sched gobuf // 调度上下文(保存的寄存器)
goid int64 // goroutine ID
m *m // 当前绑定的 M
atomicstatus uint32 // goroutine 状态(原子操作)
preempt bool // 抢占信号
preemptStop bool // 抢占时将状态修改为 _Gpreempted
preemptShrink bool // 在同步安全点收缩栈
_panic *_panic // 最内侧的 panic
_defer *_defer // 最内侧的 defer
waiting *sudog // 等待队列
...
}
type gobuf struct {
sp uintptr // 保存的栈指针
pc uintptr // 保存的程序计数器
g guintptr // 指向 g 的指针
ret uintptr // 返回值
bp uintptr // 保存的基址指针(帧指针)
}

g 结构体有 40 多个字段,上面列出的是与调度直接相关的部分。其中 stackguard0 有双重用途:正常情况下用于栈溢出检测,被设置为 stackPreempt 时则表示调度器发出了抢占请求。

G 的状态流转#

atomicstatus 字段记录 goroutine 的状态,核心状态有以下几个:

状态描述
_Gidle刚分配,尚未初始化
_Grunnable在运行队列中,等待执行
_Grunning正在执行,拥有栈的所有权
_Gsyscall正在执行系统调用,拥有栈但不在运行队列
_Gwaiting被阻塞(channel、锁等),不在运行队列
_Gdead未被使用,可能仍有分配的栈
_Gpreempted因抢占被阻塞,等待唤醒

这些状态可以聚合为三阶段:等待中_Gwaiting_Gsyscall_Gpreempted)、可运行_Grunnable)、运行中_Grunning)。goroutine 在运行期间不断在这三个阶段间切换。

M 与 P 的关键结构#

goroutine 切换的效率不仅取决于寄存器保存的多少,更取决于 G/M/P 三者的协作方式。来看 M 和 P 的核心字段:

src/runtime/runtime2.go
type m struct {
g0 *g // 调度栈 goroutine,执行调度代码时使用
curg *g // 当前运行的用户 goroutine
p puintptr // 当前绑定的 P
nextp puintptr // 唤醒 M 时要绑定的 P
oldp puintptr // 系统调用前的 P,用于快速恢复
spinning bool // 是否正在自旋找工作
...
}
type p struct {
id int32
status uint32 // P 的状态
m muintptr // 反向指向绑定的 M
runqhead uint32 // 本地队列头
runqtail uint32 // 本地队列尾
runq [256]guintptr // 本地运行队列(环形数组)
runnext guintptr // 下一个要运行的 G(最高优先级)
gFree *g // 空闲 G 缓存链表
...
}

P 是调度的核心枢纽。每个 P 持有一个最多 256 个 G 的本地运行队列 runq,通过 runqheadrunqtail 管理的环形数组实现,无锁访问。runnext 字段存储下一个要执行的 G,它比 runq 中的 G 优先级更高,如果当前 G 还有剩余时间片,runnext 指向的 G 会继承这些时间。

M 只关心两个 goroutine:g0 用于执行调度代码,curg 是正在运行的用户 G。操作系统线程本身并不知道 goroutine 的存在,它只看到自己在执行不同的函数。

需要保存的寄存器#

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

gogo 如何模拟 CALL#

gogo 用 JMP 而非 CALL 跳转到目标函数,但目标函数返回时需要知道回到哪里。Go 的解决方案是在创建 G 时(newproc1),将 goexit 的地址压入 G 的栈中,作为”返回地址”。gostartcallfn 完成这个操作:

src/runtime/proc.go
func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
sp := buf.sp
sp -= sys.PtrSize // 栈腾出一个指针的空间
*(*uintptr)(unsafe.Pointer(sp)) = buf.pc // 把 goexit 的地址压入栈
buf.sp = sp
buf.pc = uintptr(fn) // pc 设为 fn 的地址
}

这样,当 G 的函数执行 RET 指令时,CPU 从栈顶弹出 goexit 的地址并跳转过去,G 的生命周期就自然结束了。gogo 的 JMP + 栈上预埋返回地址,完美模拟了一次 CALL 调用。

栈切换的底层机制#

goroutine 的栈#

每个 goroutine 有自己独立的栈,初始大小为 2KB(可增长)。栈由 stack 结构体描述,记录了栈的低地址边界 lo 和高地址边界 hi,栈从高地址向低地址增长:

src/runtime/runtime2.go
type stack struct {
lo uintptr // 栈底,低地址
hi uintptr // 栈顶,高地址
}

stackguard0 字段位于 lo + _StackGuard 的位置(_StackGuard 通常为 1024 字节),作为栈溢出的预警线。当 SP 低于 stackguard0 时,说明栈空间即将耗尽,需要触发 morestack 增长栈。

graph TB subgraph goroutine_A["goroutine A 的栈(高地址 → 低地址)"] A1["stack.hi"] --> A2["frame 3"] A2 --> A3["frame 2"] A3 --> A4["frame 1 ← SP_A"] A4 --> A5["stack.lo"] end subgraph goroutine_B["goroutine B 的栈"] B1["stack.hi"] --> B2["frame 2"] B2 --> B3["frame 1 ← SP_B"] B3 --> B4["stack.lo"] end

切换过程#

  • 切换前(运行 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 栈上执行

g0 与用户 G 的区别#

每个 M 都有两个特殊的 goroutine:g0curgg0 是 M 的系统栈 goroutine,栈大小固定为 64KB,不会增长也不会收缩。curg 是当前 M 上运行的用户 goroutine,栈从 2KB 起步,按需增长。

两者的分工很明确:用户代码在 curg 的栈上执行,调度代码在 g0 的栈上执行。当 runtime 需要进行调度决策、栈增长、GC 扫描等操作时,必须通过 mcall 切换到 g0 栈,原因是这些操作可能需要比用户 G 当前栈更大的空间,而且它们本身不能触发栈增长(否则会递归)。g0 的 64KB 固定栈保证了这些操作有足够的空间可用。

从 TLS(线程本地存储)的角度看,切换 goroutine 的本质就是修改 TLS 中的 g 指针:gogo 将其设为目标用户 G,mcall 将其设为 g0。M 通过这个指针判断”当前正在为谁工作”。

调度循环:schedule → execute → gogo → goexit → schedule#

前文分析了 gogomcall 两条切换路径,但它们只是调度循环的两个环节。完整的调度循环如下:

flowchart LR A["schedule()<br/>选 G"] --> B["execute()<br/>绑定 M/G"] B --> C["gogo()<br/>切栈+跳转"] C --> D["用户 G 执行"] D --> E["goexit()"] E --> F["goexit1()"] F --> G["mcall()<br/>切到 g0 栈"] G --> H["goexit0()<br/>清理+放入 gFree"] H --> A

这个循环永远不会返回。每一轮调度从 schedule() 开始,选中一个可运行的 G,通过 execute()gogo() 让它在 M 上运行,G 执行完毕后通过 goexit 系列函数清理现场,最终回到 schedule() 开始新一轮调度。

schedule:选择下一个 G#

src/runtime/proc.go
func schedule() {
_g_ := getg()
var gp *g
var inheritTime bool
// 每隔 61 次调度,检查全局队列,保证公平
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)
unlock(&sched.lock)
}
// 从 P 的本地队列获取
if gp == nil {
gp, inheritTime = runqget(_g_.m.p.ptr())
}
// 本地队列空了,阻塞查找
if gp == nil {
gp, inheritTime = findrunnable()
}
execute(gp, inheritTime)
}

schedule 的查找顺序体现了调度的优先级:

  1. 全局队列(约每 61 次调度检查一次):保证全局队列中的 G 不会饿死
  2. P 的本地队列:无锁访问,最快路径
  3. findrunnable:阻塞查找,包括本地队列、全局队列、网络轮询器、工作窃取

为什么是 61?这是一个经验值,既保证全局队列中的 G 有机会被调度,又不会因为频繁访问全局队列(需要加锁)而影响性能。

execute:准备运行 G#

src/runtime/proc.go
func execute(gp *g, inheritTime bool) {
_g_ := getg()
_g_.m.curg = gp // M 绑定新 G
gp.m = _g_.m
casgstatus(gp, _Grunnable, _Grunning) // 状态迁移
gp.preempt = false
gp.stackguard0 = gp.stack.lo + _StackGuard
if !inheritTime {
_g_.m.p.ptr().schedtick++
}
gogo(&gp.sched) // 切换到 G 的栈执行
}

execute 完成了状态迁移(_Grunnable_Grunning)和 M-G 绑定,然后调用 gogo 切换到 G 的栈开始执行。inheritTime 参数控制是否继承上一个 G 的时间片:如果是抢占导致的切换,新 G 会获得完整时间片;如果是时间片用完的切换,新 G 继承剩余时间。

goexit:G 执行完毕的收尾#

当用户 G 的函数正常返回时,控制流会跳转到 goexit。这是因为 newproc1 在创建 G 时,将 sched.pc 设为 goexit 的地址,并通过 gostartcallfn 把它压入栈中。当 G 的函数返回时,ret 指令弹出栈顶的 goexit 地址并跳转过去。

src/runtime/proc.go
func goexit0(gp *g) {
_g_ := getg()
casgstatus(gp, _Grunning, _Gdead) // G 变为死亡状态
gp.m = nil
...
dropg() // 解除 M 和 G 的绑定
gfput(_g_.m.p.ptr(), gp) // G 放入 P 的空闲链表,复用
schedule() // 重新进入调度循环
}

goexit0 在 g0 栈上执行,将 G 标记为 _Gdead,清理字段,然后放入 P 的 gFree 链表等待复用。最后调用 schedule() 开始新一轮调度。

调度循环的栈空间问题#

一个有意思的问题:调度循环中的函数调用永远不返回,栈空间会不会耗尽?答案是不会。关键在于 mcall 每次切换到 g0 栈时,都是切换到 g0.sched.sp 所指的固定位置。因为 schedule 之后的函数链永远不返回,所以重用上一轮调度时的栈内存是安全的,每次调度循环都会覆盖上一轮的栈数据,相当于一种尾调用优化。

工作窃取:当 P 的本地队列空了#

schedule 发现 P 的本地队列没有可运行的 G 时,会调用 findrunnable 阻塞查找。这个函数的查找范围很广,其中最核心的机制就是工作窃取(work stealing)。

findrunnable 的查找顺序#

// src/runtime/proc.go(简化)
func findrunnable() (gp *g, inheritTime bool) {
// 1. 本地队列
if gp, inheritTime := runqget(_g_.m.p.ptr()); gp != nil {
return gp, inheritTime
}
// 2. 全局队列
if sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 0)
unlock(&sched.lock)
if gp != nil {
return gp, false
}
}
// 3. 网络轮询器
if netpollinited() {
if gp := netpoll(0); gp != nil {
...
return gp, false
}
}
// 4. 工作窃取:从其他 P 偷一半
for i := 0; i < 4; i++ {
for enum := stealOrder.start(); !enum.done(); enum.next() {
if gp := runqsteal(_g_.m.p.ptr(), allp[enum.position()], ...); gp != nil {
return gp, false
}
}
}
// 5. 再次检查全局队列、网络轮询器
...
}

工作窃取的规则:当 P2 发现自己的本地队列空了,会随机选一个 P1,从 P1 的本地队列偷走一半的 G。这种随机选择避免了多个 P 同时偷同一个 P 的竞争。

runqsteal 与 runqget 的实现#

src/runtime/proc.go
func runqsteal(_p_, p2 *p, stealRunNextSg bool) *g {
t := _p_.runqtail
n := runqget2(p2, &_p_.runq, t, runqsize, stealRunNextSg)
if n == 0 {
return nil
}
n-- // 留一个返回给调用者
_p_.runqtail = t + n
return _p_.runq[(t+n)%uint32(len(_p_.runq))].ptr()
}

runqsteal 调用 runqget2 从目标 P 的本地队列批量获取 G,取走一半(最多 32 个),其中一个直接返回给调用者立即执行,其余放入当前 P 的本地队列。

stealRunNextSg 参数决定是否偷目标 P 的 runnextrunnext 是 P 上优先级最高的 G,通常只在当前 P 即将进入休眠时才会偷它。

runqput:G 入队的优先级#

src/runtime/proc.go
func runqput(_p_ *p, gp *g, next bool) {
if next {
// 优先放入 runnext
retryNext:
oldnext := _p_.runnext
if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
goto retryNext
}
if oldnext == 0 {
return
}
// 把被挤掉的旧 runnext 放入 runq
gp = oldnext.ptr()
}
// 放入本地队列的尾部
retry:
h := atomic.LoadAcq(&_p_.runqhead)
t := _p_.runqtail
if t-h < uint32(len(_p_.runq)) {
_p_.runq[t%uint32(len(_p_.runq))].set(gp)
atomic.StoreRel(&_p_.runqtail, t+1)
return
}
// 本地队列满了,转移到全局队列
runqputslow(_p_, gp, h, t)
}

next=true 时,G 被放入 runnext,旧的 runnext 被挤到 runq 中。这就是为什么 goready 唤醒的 G 会比其他 G 更快被执行。当本地队列满(256 个)时,runqputslow 会把前 128 个 G 连同新 G 一起转移到全局队列,通过链表批量操作减少锁竞争。

Hand-off:系统调用时的 P 交接#

当 G 执行系统调用时,对应的 M 会在内核态阻塞,等待系统调用返回。如果 P 一直跟着 M,那么这个 P 上的其他 G 都得等。Go 的解决方案是 hand-off:M 进入系统调用时把 P 交出去,让其他 M 接手执行。

entersyscall:进入系统调用#

src/runtime/proc.go
func reentersyscall(pc, sp uintptr) {
_g_ := getg()
_g_.stackguard0 = stackPreempt // 禁止栈增长
save(pc, sp) // 保存当前 PC/SP
casgstatus(_g_, _Grunning, _Gsyscall) // 状态迁移
pp := _g_.m.p.ptr()
pp.m = 0 // P 与 M 解绑
_g_.m.oldp.set(pp) // 记住原来的 P
_g_.m.p = 0
atomic.Store(&pp.status, _Psyscall) // P 进入 _Psyscall 状态
}

进入系统调用时做了三件关键的事:把 G 标记为 _Gsyscall,把 P 从 M 上摘下来并标记为 _Psyscall,把原来的 P 记到 m.oldp 中以便快速恢复。

P 进入 _Psyscall 后,后台监控线程 sysmon 会检查它:如果这个 P 的系统调用超过 10ms,sysmon 会调用 retake 把 P 抢走,交给其他 M 使用。

exitsyscall:退出系统调用#

src/runtime/proc.go
func exitsyscall() {
_g_ := getg()
oldp := _g_.m.oldp.ptr()
_g_.m.oldp = 0
// 快速路径:尝试重新绑定原来的 P
if exitsyscallfast(oldp) {
casgstatus(_g_, _Gsyscall, _Grunning)
return
}
// 慢速路径:切换到 g0 栈,重新调度
mcall(exitsyscall0)
}

退出系统调用时有两条路径。快速路径:原来的 P 还在 _Psyscall 状态(没被 sysmon 抢走),直接重新绑定,G 可以继续执行。慢速路径:原来的 P 已经被抢走,需要切换到 g0 栈,通过 exitsyscall0 获取空闲 P 或将 G 放入全局队列,然后进入 schedule() 重新调度。

快速路径的设计体现了”乐观策略”:大多数系统调用很快返回,P 大概率还在等,直接拿回来是最快的。只有当系统调用耗时较长、P 已被抢走时,才走慢速路径。

goroutine 的创建:newproc#

每次写 go func() 时,编译器会将其转换为对 runtime.newproc 的调用。理解 newproc 有助于理解 G 是如何被放入调度队列的。

src/runtime/proc.go
func newproc(siz int32, fn *funcval) {
argp := add(unsafe.Pointer(&fn), sys.PtrSize) // 跳过 fn,拿到参数地址
gp := getg()
pc := getcallerpc() // 调用者的返回地址
systemstack(func() {
newg := newproc1(fn, argp, siz, gp, pc)
_p_ := getg().m.p.ptr()
runqput(_p_, newg, true) // 放入 P 的本地队列,next=true
if mainStarted {
wakep() // 唤醒空闲 P/M 来执行
}
})
}

newproc 切换到 g0 栈执行 newproc1,因为创建 G 可能涉及内存分配,不能在用户 G 的小栈上进行。

newproc1:创建 G 的核心逻辑#

src/runtime/proc.go
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
_g_ := getg()
_p_ := _g_.m.p.ptr()
// 从 P 的 gFree 链表获取空闲 G
newg := gfget(_p_)
if newg == nil {
// 没有空闲 G,创建新的,分配 2KB 栈
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg)
}
// 计算新 G 的栈顶位置,拷贝参数到新栈
totalSize := 4*sys.RegSize + uintptr(narg) + sys.MinFrameSize
sp := newg.stack.hi - totalSize
if narg > 0 {
memmove(unsafe.Pointer(sp), argp, uintptr(narg))
}
// 设置调度信息
newg.sched.sp = sp
newg.sched.pc = funcPC(goexit) + sys.PCQuantum // 关键:pc 设为 goexit
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn) // 把 fn 的地址压栈,覆盖 pc
casgstatus(newg, _Gdead, _Grunnable)
newg.goid = int64(_p_.goidcache)
_p_.goidcache++
return newg
}

这里有三个关键设计:

  1. G 的复用gfget 先从 P 的 gFree 链表获取空闲 G,P 的链表空了就从全局调度器的 gFree 批量转移 32 个,避免频繁创建销毁
  2. pc 设为 goexitnewg.sched.pc 先设为 goexit 的地址,然后 gostartcallfn 把它压入栈中(模拟 CALL 指令压入返回地址),再把 fn 的地址设为新的 pc。这样当 fn 执行完毕返回时,会跳转到 goexit 完成收尾
  3. 栈的 2KB 起步malg(_StackMin) 分配 2KB 栈,通过 stackalloc 从堆上分配

栈增长:morestack#

goroutine 的初始栈只有 2KB,函数调用层级深了就可能不够用。Go 采用了分段检查、按需增长的策略。

编译器插入的栈检查#

编译器在每个函数的入口插入栈溢出检查代码。当 SP 逼近 stackguard0 时,触发 morestack

// 编译器为每个函数生成的入口代码(伪代码)
fn_entry:
CMPQ SP, stackguard0(g)
JBE morestack_handler // SP <= stackguard0,栈不够了
... // 正常函数体

morestack 到 newstack#

src/runtime/proc.go
func newstack() {
_g_ := getg()
// 检查是否是抢占请求
if _g_.stackguard0 == stackPreempt {
// 先检查是否可以安全抢占
if canPreemptM(_g_.m) {
gopreempt_m(_g_) // 让出 CPU
return
}
// 不能抢占,恢复 stackguard0 继续执行
_g_.stackguard0 = _g_.stack.lo + _StackGuard
}
// 分配新栈(2 倍大小)
oldsize := _g_.stack.hi - _g_.stack.lo
newsize := oldsize * 2
newstack := stackalloc(uint32(newsize))
// 拷贝旧栈内容到新栈
memmove(unsafe.Pointer(newstack.hi-UsedSize), ..., UsedSize)
// 调整栈上的指针(指向旧栈的地址需要更新为新栈地址)
gentraceback(..., adjustpointer, ...)
// 切换到新栈
_g_.stack = newstack
_g_.stackguard0 = newstack.lo + _StackGuard
gogo(&_g_.sched) // 跳回 G 继续执行
}

栈增长的核心步骤:

  1. 判断是抢占还是栈溢出stackguard0 == stackPreempt 表示抢占请求,否则是真正的栈空间不足
  2. 分配新栈:新栈大小为旧栈的 2 倍
  3. 拷贝栈帧:把旧栈中正在使用的部分整体搬移到新栈
  4. 调整指针:栈上可能有指向栈内地址的指针(闭包、defer 等),需要遍历栈帧将它们调整为指向新栈的对应位置
  5. 切换到新栈:更新 g.stackstackguard0,通过 gogo 跳回

栈收缩(shrink)发生在 GC 的标记阶段:如果 G 的栈使用量不到容量的 1/4,GC 会将其收缩为当前使用量的 2 倍,最小为 2KB。收缩在下一次 morestack 时才真正释放旧栈。

goroutine 切换的性能实测#

基准测试#

func BenchmarkGoroutineSwitch(b *testing.B) {
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 倍

异步抢占:Go 1.14 的关键改进#

在 Go 1.14 之前,goroutine 的抢占依赖协作式抢占(cooperative preemption):编译器在函数调用和跳转点插入栈溢出检查,如果当前 goroutine 的栈需要增长或已被标记为抢占,就主动让出 CPU。问题在于,如果一个 goroutine 执行的是无函数调用的紧密循环(如 for {} 或纯计算循环),它永远不会碰到检查点,调度器无法抢走它的 P,其他 goroutine 会被饿死,GC 的 STW 也可能延迟数十秒。

Go 1.14 引入了基于信号的异步抢占(signal-based async preemption),解决了这个问题。

实现机制#

异步抢占的工作原理:

  1. sysmon 检测:后台监控线程 sysmon 发现某个 G 运行超过 10ms,设置其 stackguard0 = stackPreempt
  2. 发送信号:向运行该 G 的 M 发送 SIGURG 信号
  3. 信号处理器:M 收到信号后,在信号栈(sigaltstack)上执行 runtime.sighandler
  4. doSigPreempt:sighandler 调用 doSigPreempt,修改被中断 goroutine 的上下文,将其 PC 和 SP 保存到 gobuf,然后设置恢复点为 asyncPreempt
  5. 恢复执行:信号返回后,goroutine 不回到原来的代码,而是跳转到 asyncPreempt 函数
  6. asyncPreempt:调用 gopreempt_m,将当前 G 放入全局队列,然后执行 schedule() 切换到其他 G

源码路径:

为什么选择 SIGURG?#

Go 选择 SIGURG 而非其他信号有几个考虑:

  • SIGURG 是”紧急数据”信号,在正常程序中极少使用,不太可能与用户代码注册的信号处理器冲突
  • 它不是标准 POSIX 信号,调试器和 profiler 通常不会拦截它
  • 它是可安全中断的信号,不会像 SIGSEGV 那样暗示程序有 bug

对 GC 的影响#

异步抢占对 GC 的 STW 延迟影响巨大。在 Go 1.13 及之前,如果某个 goroutine 在紧密循环中,GC 的 stopTheWorld 可能需要等待数秒(等该 goroutine 碰到函数调用点)。Go 1.14 之后,stopTheWorld 通过信号可以立即抢占任何 goroutine,STW 延迟通常降到微秒级别。

flowchart TD A["sysmon 检测 G 运行 &gt; 10ms"] --> B["signalM: 向 M 发送 SIGURG"] B --> C["内核将信号投递给 M"] C --> D["sighandler 在 sigaltstack 上执行"] D --> E["doSigPreempt: 保存 G 的 PC/SP 到 gobuf"] E --> F["修改恢复地址为 asyncPreempt"] F --> G["信号返回,G 跳转到 asyncPreempt"] G --> H["asyncPreempt → gopreempt_m"] H --> I["G 被放入全局队列"] I --> J["schedule() 选择下一个 G"] style B fill:#FF9800,color:#fff style G fill:#4CAF50,color:#fff

常见问题 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 切换之所以比线程切换快 10-100 倍,核心在于它始终在用户态完成,只保存最少的寄存器,只做最少的操作。gogo 的核心是三条指令,恢复 SP、恢复 PC、JMP,修改了栈指针和指令指针,CPU 就自动在目标 goroutine 的栈上运行了,不需要进入内核、不需要切换页表、不需要刷新 TLB。但这个”快”是有边界的:goroutine 切换的开销大约 50-170ns,单次确实可以忽略,但在每秒百万次 channel 操作的极端场景下,切换成本会累积到可观测的程度。

goroutine 切换不是孤立的操作,它嵌入在一个完整的调度循环中:schedule 从本地队列、全局队列或通过工作窃取找到可运行的 G,execute 完成状态迁移和 M-G 绑定,gogo 切换栈并跳转执行,G 执行完毕后通过 goexit 回到 schedule 开始新一轮。这个循环永远不返回,但因为 mcall 每次都切换到 g0 栈的固定位置,栈空间不会耗尽。

G/M/P 三者的协作是调度效率的基础。P 的本地队列(256 个 G 的环形数组,无锁访问)和 runnext(最高优先级)让 G 的入队出队几乎零开销;工作窃取让负载自动均衡;hand-off 机制在 M 阻塞于系统调用时把 P 交给其他 M,避免 P 上的 G 被连带阻塞。newproc 创建 G 时优先复用 gFree 链表中的空闲 G,2KB 的初始栈和按需增长策略(morestack)让 G 的创建成本远低于线程。

异步抢占(Go 1.14)是另一个重要的设计决策:通过 SIGURG 信号,运行时可以在任何安全点中断 goroutine,即使它正在执行无函数调用的紧密循环。这让 GC 的 STW 延迟从秒级降到了微秒级,也消除了 goroutine 饥饿的可能性。理解 goroutine 切换的真正价值在于理解 Go 调度器的设计前提,所有调度决策都在用户态做出。mcall 切换到 g0 栈是因为 runtime 内部操作(GC、栈增长)需要稳定的系统栈;gopark 挂起 goroutine 而不是让 M 阻塞,是因为 M 是昂贵资源,挂起 goroutine 只需把它从运行队列移除。如果你发现 goroutine 切换开销成了瓶颈,解决方案是减少切换次数,批量处理、合并 channel 操作、用 sync.Pool 替代频繁创建销毁。

参考资料#

支持与分享

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

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

部分信息可能已经过时