Go 语言之所以能够以极低的开发成本编写高并发网络服务,很大程度上归功于其内置的用户态调度器与低延迟并发垃圾回收器(GC)。但在排查长尾延迟(P999 抖动)、CPU 软中断飙高或内存无法及时归还操作系统等问题时,理解 runtime 底层的运作机制必不可少。
一、GMP 调度模型的设计演进
在 Go 1.0 的早期实现中,只有 Goroutine(G)和系统内核线程(M),所有 M 从同一个全局运行队列中获取 G,每次调度都需要加全局互斥锁 sched.lock,在多核机器上锁竞争极其剧烈。
从 Go 1.1 起引入了 逻辑处理器 P(Processor),默认数量等于 GOMAXPROCS:
- G(Goroutine):包含协程栈、程序计数器
pc以及调度状态(_Grunnable、_Grunning、_Gwaiting等),初始栈大小仅 2KB。 - M(Machine):对应操作系统内核线程,由 OS 调度器分配到物理 CPU 核心上运行。
- P(Processor):维护容量为 256 的本地无锁环形运行队列
runq、单独的优先槽位runnext,以及线程本地内存缓存mcache。
协程查找与工作窃取(Work Stealing)顺序
当绑定了 P 的内核线程 M 需要寻找下一个可执行的 G 时,schedule() 函数按照以下优先级查找:
// runtime/proc.go 调度查找核心顺序简化示意
func findRunnable(p *p) *g {
// 1. 每调度 61 次,优先检查一次全局队列,防止全局队列中的 G 饥饿
if p.schedtick%61 == 0 && sched.runqsize > 0 {
if gp := globrunqget(p, 1); gp != nil {
return gp
}
}
// 2. 从当前 P 的 runnext 和本地环形队列 runq 中无锁获取
if gp, inheritTime := runqget(p); gp != nil {
return gp
}
// 3. 本地队列为空,尝试从全局队列批量拉取一部分 G 到本地
if sched.runqsize > 0 {
if gp := globrunqget(p, 0); gp != nil {
return gp
}
}
// 4. 检查网络轮询器 netpoller (epoll/kqueue) 是否有就绪协程
if netpollerReady() {
// ...
}
// 5. 随机选择其他 P,从其本地队列尾部窃取一半的 G (Work Stealing)
return stealWork(p)
}二、基于信号的异步抢占(Go 1.14+)
在 Go 1.14 之前,调度抢占依赖于编译器在函数序言(Function Prologue)插入的栈扩容检查指令。如果某个 Goroutine 执行一段不包含任何函数调用的纯算术死循环,该 P 将被永久霸占,甚至导致 GC 在等待所有 P 进入安全点(Safe Point)时完全卡死。
Go 1.14 引入了基于 SIGURG 信号的异步抢占:后台监控线程 sysmon 发现某个 G 连续运行超过 10ms 后,向对应线程 M 发送 SIGURG 信号。操作系统中断线程执行并进入信号处理函数 doSigPreempt,修改寄存器上下文将控制流跳转到 asyncPreempt,从而强制让出当前 CPU。
三、三色标记与混合写屏障(Hybrid Write Barrier)
Go 的并发 GC 采用三色标记清扫算法。为了在用户协程与后台标记协程并发运行时不漏标存活对象,必须满足强/弱三色不变式。
若对栈上的每一次指针写入都开启写屏障,性能损耗是不可接受的。そのため Go 1.7 采用 Dijkstra 插入写屏障(仅对堆内存生效),但在标记结束阶段必须 Stop The World(STW)重新扫描所有活跃 Goroutine 的栈,在协程数量达到数十万时 STW 可能长达数十毫秒。
Go 1.8 引入了结合 Dijkstra 插入屏障与 Yuasa 删除屏障优点的 混合写屏障:
- GC 扫描开始时,将栈上可达的所有对象全部标记为黑色(无需二次重扫栈);
- GC 期间,任何在栈上新创建的对象一律直接标记为黑色;
- 堆上对象发生指针赋值
*slot = ptr时,将原被覆盖的对象*slot涂灰,同时将新指向的对象ptr涂灰:
// 混合写屏障伪代码
writePointer(slot, ptr):
shade(*slot) // Yuasa 删除屏障思想:保护被断开引用的旧对象
if currentStackIsGrey():
shade(ptr) // Dijkstra 插入屏障思想:当栈未扫完时保护新引用对象
*slot = ptr通过这一设计,Go 将大多数场景下的 STW 时间稳定控制在亚毫秒级别。