“goroutine 切换比线程切换快”,每个 Go 开发者都听过这句话,但很少有人能说清楚:goroutine 切换时 CPU 到底做了什么?哪些寄存器需要保存?栈是怎么切换的?为什么比线程切换快?
本文深入 runtime/asm_amd64.s 的汇编代码,逐指令解析 goroutine 的上下文切换过程。
线程切换 vs goroutine 切换
本质区别
| 操作 | 线程切换(OS) | goroutine 切换(Go) |
|---|---|---|
| 触发方式 | 时钟中断/系统调用 | runtime 主动调用 |
| 特权级切换 | 用户态 → 内核态 → 用户态 | 始终在用户态 |
| 保存的寄存器 | 所有通用寄存器 + 浮点 + SIMD | SP + PC + DX + BX(约 5 个) |
| 栈切换 | 切换内核栈 | 切换用户栈 |
| TLB 影响 | 可能刷新 TLB | 无影响 |
| 耗时 | ~1-10μs | ~100-200ns |
goroutine 的上下文
goroutine 的上下文(Context)是指切换时需要保存和恢复的状态:
g 结构体中的调度相关字段
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 的核心字段:
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,通过 runqhead 和 runqtail 管理的环形数组实现,无锁访问。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-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
gogo 如何模拟 CALL
gogo 用 JMP 而非 CALL 跳转到目标函数,但目标函数返回时需要知道回到哪里。Go 的解决方案是在创建 G 时(newproc1),将 goexit 的地址压入 G 的栈中,作为”返回地址”。gostartcallfn 完成这个操作:
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,栈从高地址向低地址增长:
type stack struct { lo uintptr // 栈底,低地址 hi uintptr // 栈顶,高地址}stackguard0 字段位于 lo + _StackGuard 的位置(_StackGuard 通常为 1024 字节),作为栈溢出的预警线。当 SP 低于 stackguard0 时,说明栈空间即将耗尽,需要触发 morestack 增长栈。
切换过程
- 切换前(运行 goroutine A):
RSP = SP_A(指向 A 的栈顶)RIP = A 的当前指令
- gogo(gobuf_B):
RSP = SP_B(切换到 B 的栈顶)RIP = PC_B(跳转到 B 的执行位置)
- 切换后(运行 goroutine B):
RSP = SP_BRIP = 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 栈上执行 |
g0 与用户 G 的区别
每个 M 都有两个特殊的 goroutine:g0 和 curg。g0 是 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
前文分析了 gogo 和 mcall 两条切换路径,但它们只是调度循环的两个环节。完整的调度循环如下:
这个循环永远不会返回。每一轮调度从 schedule() 开始,选中一个可运行的 G,通过 execute() 和 gogo() 让它在 M 上运行,G 执行完毕后通过 goexit 系列函数清理现场,最终回到 schedule() 开始新一轮调度。
schedule:选择下一个 G
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 的查找顺序体现了调度的优先级:
- 全局队列(约每 61 次调度检查一次):保证全局队列中的 G 不会饿死
- P 的本地队列:无锁访问,最快路径
- findrunnable:阻塞查找,包括本地队列、全局队列、网络轮询器、工作窃取
为什么是 61?这是一个经验值,既保证全局队列中的 G 有机会被调度,又不会因为频繁访问全局队列(需要加锁)而影响性能。
execute:准备运行 G
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 地址并跳转过去。
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 的实现
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 的 runnext。runnext 是 P 上优先级最高的 G,通常只在当前 P 即将进入休眠时才会偷它。
runqput:G 入队的优先级
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:进入系统调用
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:退出系统调用
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 是如何被放入调度队列的。
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 的核心逻辑
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}这里有三个关键设计:
- G 的复用:
gfget先从 P 的gFree链表获取空闲 G,P 的链表空了就从全局调度器的gFree批量转移 32 个,避免频繁创建销毁 - pc 设为 goexit:
newg.sched.pc先设为goexit的地址,然后gostartcallfn把它压入栈中(模拟 CALL 指令压入返回地址),再把fn的地址设为新的pc。这样当fn执行完毕返回时,会跳转到goexit完成收尾 - 栈的 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
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 继续执行}栈增长的核心步骤:
- 判断是抢占还是栈溢出:
stackguard0 == stackPreempt表示抢占请求,否则是真正的栈空间不足 - 分配新栈:新栈大小为旧栈的 2 倍
- 拷贝栈帧:把旧栈中正在使用的部分整体搬移到新栈
- 调整指针:栈上可能有指向栈内地址的指针(闭包、defer 等),需要遍历栈帧将它们调整为指向新栈的对应位置
- 切换到新栈:更新
g.stack和stackguard0,通过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),解决了这个问题。
实现机制
异步抢占的工作原理:
- sysmon 检测:后台监控线程 sysmon 发现某个 G 运行超过 10ms,设置其
stackguard0 = stackPreempt - 发送信号:向运行该 G 的 M 发送
SIGURG信号 - 信号处理器:M 收到信号后,在信号栈(sigaltstack)上执行
runtime.sighandler - doSigPreempt:sighandler 调用
doSigPreempt,修改被中断 goroutine 的上下文,将其 PC 和 SP 保存到gobuf,然后设置恢复点为asyncPreempt - 恢复执行:信号返回后,goroutine 不回到原来的代码,而是跳转到
asyncPreempt函数 - asyncPreempt:调用
gopreempt_m,将当前 G 放入全局队列,然后执行schedule()切换到其他 G
源码路径:
- 信号发送:
signalM在 proc.go 中 - 信号处理:
sighandler在 signal_linux_amd64.go 中 - 抢占入口:
asyncPreempt在 preempt.go 中 - 抢占执行:
gopreempt_m在 proc.go 中
为什么选择 SIGURG?
Go 选择 SIGURG 而非其他信号有几个考虑:
SIGURG是”紧急数据”信号,在正常程序中极少使用,不太可能与用户代码注册的信号处理器冲突- 它不是标准 POSIX 信号,调试器和 profiler 通常不会拦截它
- 它是可安全中断的信号,不会像
SIGSEGV那样暗示程序有 bug
对 GC 的影响
异步抢占对 GC 的 STW 延迟影响巨大。在 Go 1.13 及之前,如果某个 goroutine 在紧密循环中,GC 的 stopTheWorld 可能需要等待数秒(等该 goroutine 碰到函数调用点)。Go 1.14 之后,stopTheWorld 通过信号可以立即抢占任何 goroutine,STW 延迟通常降到微秒级别。
常见问题 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 替代频繁创建销毁。
参考资料
- Go Runtime Source: asm_amd64.s - gogo/mcall 汇编实现
- Go Runtime Source: runtime2.go - g/gobuf/m/p 结构体
- Go Runtime Source: proc.go - schedule/execute/findrunnable/runqput/runqsteal/newproc
- Go Runtime Source: preempt.go - 异步抢占实现
- Go Runtime Source: stack.go - 栈增长与收缩
- Go 1.14 asynchronous preemption - 基于信号的抢占
- Analysis of the Go runtime scheduler - 学术分析
- Scalable Go Scheduler Design Doc - GMP 模型与工作窃取设计
- Go 语言设计与实现 - 调度器 - 调度器演进与数据结构详解
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






