前一阵线上有个 Go 服务出了怪问题:某个接口的耗时从几毫秒直接涨到几十秒,CPU 跑满,但没有 panic,也没有死锁。用 pprof 抓火焰图,发现大量业务 goroutine 全都卡在一个纯计算函数里,而那是一个没有任何函数调用的 for 循环。这正好牵扯出 Go 社区里被反复讨论的问题:goroutine 到底有没有“时间片”?为什么一个死循环能把其他 goroutine 一起拖慢?这些疑问归结起来,其实就是 Go 协程调度器的时间片与公平性实现问题。
很长一段时间里,很多人对 Go 调度的理解停留在“goroutine 很轻量,调度器会自动分配 CPU”,一旦遇到异常,就会用操作系统的线程时间片模型去套 Go,结果越套越糊涂。这篇文章我会从 GMP 模型出发,拆开 Go 调度器的队列设计和抢占机制,讲清楚 Go 的时间片本质是什么,公平性是怎么实现的,什么场景下公平性会失效,以及工程上该怎么观测和规避。适合在业务里碰到过“goroutine 抢不到 CPU”或者“死循环卡死服务”的人,也适合想把 GMP 调度搞明白的 Go 开发者。
1. 先拆概念:goroutine 的时间和线程时间片不是一回事
1.1 操作系统的时间片靠中断强制剥夺
操作系统的线程调度,依赖的是内核时钟中断。系统会把 CPU 时间切成很短的片,每个线程只能连续运行一个时间片的长度,通常是几毫秒到几十毫秒。时间一到,时钟中断触发,内核抢占该线程,保存上下文,再从就绪队列里选下一个线程运行。
这个过程的关键在于“强制”。线程自己没得选,哪怕它正在一个无限循环里,也挡不住中断。内核通过硬件定时器保证没有任何一个线程能无限霸占 CPU,这也就成了“公平”的底线。
1.2 GMP 模型:goroutine 被调度,但不由内核调度
Go 的调度和操作系统线程调度不在一个层面。Go 运行时自己维护了一套 GMP 模型:
- G 是 goroutine,代表一个待执行的任务;
- M 是操作系统线程,真正干活的载体;
- P 是调度资源,可以理解成“持有本地运行队列的处理器”。
一个 M 要运行 G,必须先绑定一个 P。P 的数量默认等于 GOMAXPROCS,也就是运行时认为可以并行执行用户代码的 CPU 数量。
这里要注意,Go 调度器在用户态自己完成,不需要内核去切换 goroutine。内核看到的只是若干个 M(线程),它只会在线程之间做时间片轮转。goroutine 的切换、排队、迁移,全部由 Go runtime 内部决定。这就带来一个本质区别:Go 没法直接依赖硬件时钟中断来打断某个 goroutine,因为内核根本不知道 goroutine 存在。
1.3 Go 的“时间片”是事件驱动拼出来的
既然没有内核中断兜底,Go 怎么避免某个 goroutine 一直霸占 P?答案是“调度点加异步抢占”。
所谓调度点,就是代码里可能出现让出 CPU 的位置,比如函数调用、channel 操作、锁竞争、系统调用、GC 辅助标记等。这些位置会让当前 goroutine 暂停,调度器趁机切换到别的 goroutine。早期 Go 主要靠协程自己跑到调度点才让出,所以叫协作式调度。
但光靠调度点不够。如果一个 goroutine 在纯计算循环里,没有任何函数调用,它永远不会主动跑到调度点,其他 goroutine 就会饿死。Go 1.14 之后引入了基于信号的异步抢占,由后台监控线程 sysmon 发现运行时间过长的 goroutine,强制打断,这才补上了“强制让出”这环。
所以,Go 里没有一个叫“goroutine 时间片”的内核概念,但它通过调度点和异步抢占,实现了类似时间片的效果。这个效果是拼出来的,不是内核给的。
| 维度 | 线程时间片 | Go 的等效时间片 |
|---|---|---|
| 强制方 | 内核时钟中断 | sysmon + 信号 |
| 调度单位 | 线程 | goroutine |
| 时间片长度 | 固定且比较均匀 | 不固定,目标 10ms 量级 |
| 触发位置 | 任意指令 | 安全点或调度点 |
| 是否用户态可控 | 基本不可控 | 可 |
