为什么 GMP 调度器一开始没有抢占?
很多用 Go 写服务的团队都遇到过类似场景:一个 goroutine 进入了一个非常重的 for 循环,结果其他 goroutine 好像全部被卡住了,接口超时一片。有人第一反应是并发数不够,或者锁竞争,但实际上问题可能出在 Go 调度器本身。它并不像操作系统线程那样具备强制的抢占能力。在理解 Go GMP 调度器的抢占式调度之前,很多线上问题的判断都会走弯路。本文从协作式抢占和异步抢占两条实现路径说起,看看它们各自解决的问题,以及调度器是怎么演进到今天的形态。

Go 的调度模型是 GMP。M 是操作系统线程,P 是逻辑处理器,G 是需要调度的 goroutine。M 必须绑定一个 P 才能运行 G,而 P 维护着一个本地运行队列。这里的关键在于,一个正在运行的 G 什么时候会让出 P?在最早的实现里,答案几乎只有“它自己愿意”。主动调用 runtime.Gosched、因为 channel 或锁进入阻塞、发起系统调用,这些都属于 G 自己触发的让出。这种方式和操作系统的协作式多任务很像,调度器本身不打断任何正在执行的实体。
协作式调度最大的优点是简单。状态保存和恢复都发生在明确定义的点上,栈和寄存器上下文不容易出现不一致。但代价也清楚:如果一个 G 从头到尾都在一个纯计算循环里,没有任何让出机会,那它对应的 P 上的其他 G 就只能排队等着。在 Go 1.14 之前,这确实能造成线上服务雪崩。
协作式抢占:让出不是自觉,而是靠检查点
严格来说,Go 在早期也不是完全没有抢占。编译器会在编译阶段往函数调用点附近插入一些检查代码。当调度器觉得需要重新安排执行时,它会设置一个抢占标志。某个 G 在下一次进入函数调用时检查到这个标志,就会主动让出。因为是否让出仍然取决于 G 是否到达了一个函数调用点,所以这种机制通常被称为协作式抢占。
这里的“协作”对象其实是编译器和运行时,而不是用户代码。用户代码不需要显式写 Gosched,但编译器必须保证在足够多的位置留出检查点。正常情况下,网络请求、channel 操作、加锁解锁等都会产生函数调用,所以绝大多数 G 都能被及时调度出去。真正麻烦的是像下面这种代码:
func compute() int {
sum := 0
for i := 0; i < 1e10; i++ {
sum += i
}
return sum
}
这个循环内部没有函数调用,编译器没有地方插入抢占点。在 Go 1.14 之前,一旦某个 P 开始运行它,这个 P 上的其他 G 只能等循环结束。你可能会说,那就拆小任务,或者在循环里调用 runtime.Gosched。这确实是当时的常见 workaround,但面对一段已经跑上线的复杂计算逻辑,你很难保证每个循环都适合。
异步抢占:用信号强行打断运行中的 G
Go 1.14 引入了基于信号的异步抢占,英文通常叫 asynchronous preemption。核心思路是让 runtime 的监控线程 sysmon 承担巡视员的角色。sysmon 会周期性地检查所有 P 的调度状态,如果发现某个 G 已经运行了超过 10ms,就向正在运行它的 M 发送一个特殊信号。在 Linux 平台,这个信号是 SIGURG。
信号到达 M 后,会打断当前正在执行的指令流,进入 runtime 预先注册的信号处理函数。信号处理函数需要判断当前位置是不是一个“安全点”。所谓安全点,是指该位置处的寄存器、栈信息足够完整,能够安全地暂停和恢复 goroutine。如果安全,就保存上下文,把当前 G 让出,换成队列中下一个 G。如果当前正处于运行时内部敏感的临界区,不能立即打断,那就先标记“需要抢占”,等 G 进入下一个安全点再做调度。
// 伪代码:sysmon 触发异步抢占的关键路径
func sysmon() {
for {
// 短暂休眠,避免占满 CPU
usleep(20)
for _, p := range allP {
if p.curg != nil && p.curg.runTime > 10*time.Millisecond {
sendSignal(p.m, sigPreempt)
}
}
}
}
当然,真实的 runtime 实现要复杂得多,还必须判断 CGO 状态、是否正在调试等边界条件。但核心就是这样:用信号打断正在执行的 G,让调度器有机会介入。信号抢占之所以被视为“异步”,是因为 G 本身并没有在任何一行代码里主动让出,而是由运行时的监控线程从外部发起的。
| 维度 | 协作式抢占 | 异步抢占 |
|---|---|---|
| 触发方 | 编译器插入检查点,由 G 自己检查 | runtime 监控线程发送信号 |
| 切换位置 | 函数调用点 | 信号处理器判定的安全点 |
| 是否需要 G 配合 | 是,必须运行到检查点 | 否,可打断大部分指令流 |
| 主要版本 | Go 1.14 之前为主 | Go 1.14 起 |
| 典型覆盖 | 有函数调用的代码 | 含纯 CPU 循环的代码,除去临界区 |
| 额外开销 | 每次函数调用有轻微检查 | 信号处理和栈信息元数据 |
协作式与异步,并不是简单的替代关系
很多人以为 Go 1.14 是最明显的分水岭:之前的调度器没有抢占,之后的调度器有抢占。其实更准确的说法是,Go 1.14 是在原有协作式抢占的基础上,额外增加了异步抢占作为加速器。它没有完全抛弃协作式抢占,原因在于异步抢占并不是在所有场景下都安全。比如,G 正持有某些 runtime 内部锁,或者正在执行一段尚未准备好栈映射的指令时,强制中断可能破坏数据一致性。在这种情况下,runtime 会退回到协作式抢占,或者等 G 进入安全点。所以你现在看到的抢占式调度,其实是“异步抢占为主,协作式抢占兜底”的混合机制。
这也解释了一个常见现象:很多人升级到 Go 1.14 之后,发现性能并不是稳定变好。异步抢占带来的 10ms 周期和信号处理,会让某些 CPU 密集型 goroutine 切换得更频繁,结论是增加了额外的上下文切换和缓存压力。在低延迟服务里,这种波动是可以感知到的。
常见误区与实战建议
下面几个判断是我在讨论 Go 调度器时经常听到的,踩坑率很高。
- 迷信 Go 1.14 之后就没有饿死问题了。如果 G 正处于无法安全抢占的运行时临界区,或者被系统调用持续占用,仍然可能让其他 G 等待。不过这类场景通常很短,不算主要矛盾。
- 认为 10ms 是一个精确到毫秒的调度周期。它只是监控线程用来触发抢占的一个参考阈值,具体是否真的切换,还取决于安全点、P 的本地队列状态和运行时是否允许。
- 觉得 runtime.Gosched 已经无用。异步抢占虽然能兜底,但主动让出的代价通常比信号抢占小,尤其在明确知道自己还要跑很久的循环里,主动让出能避免后面更大的调度抖动。
- 遇到调度问题就调大 GOMAXPROCS。GOMAXPROCS 决定 P 的数量,调大只增加并行度,不会让一个已经占有 P 的 G 让出来,反而可能因为线程增多导致更多上下文切换。
如果你怀疑程序里有“抢占异常”导致的低吞吐,可以用 GODEBUG 参数关闭异步抢占,观察前后差异:
GODEBUG=asyncpreemptoff=1 go run main.go
这能帮你模拟 Go 1.14 之前的行为,确认问题是否真的和异步抢占相关。在实际代码里,你还可以在耗时循环的每一轮迭代中主动调用 runtime.Gosched,但要注意频率,频繁让出会放大调度开销。对于大多数网络服务,不需要额外处理;只有当你自己写了 CPU 密集算法、批处理任务或者长时间运行的并发循环时,才需要认真考虑这些方法。
一个更现实的情况是,有的团队从 Go 1.12 升级到 Go 1.16,原本几分钟跑完的批处理任务反而变成了几十分钟。排查后发现代码里到处是手动让出 goroutine 的补偿逻辑,本来是为了解决旧版本饿死问题,升级后异步抢占已经频繁打断,再加上主动让出,反而导致过度切换。升级 Go 版本后,重新审视这些“抢占补偿”代码,是一个很重要的工程动作。
小结
抢占式调度不是 Go 一开始就有的能力。它经历了从编译器检查点到信号中断的演进,如今以异步抢占为主、协作式抢占为辅。理解这两者的关系,能帮助你在排查 CPU 密集任务、升级 Go 版本、优化调度延迟时少走弯路。真正重要的不是记住 Go 1.14 这个版本号,而是理解安全点和让出条件这两个核心概念。这样一来,当你的 goroutine 再次出现长时间运行的情况时,你至少知道该往哪个方向排查。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/533/