Go 调度器:从一个 Bug 理解 GMP
上周排查一个线上问题:服务的 CPU 只用了 20%,但 QPS 上不去。最终发现是文件 IO 阻塞导致线程耗尽。这让我意识到,很多人(包括之前的我)对 Go 调度器的理解只停留在”goroutine 很轻量”的层面。
今天就从这个问题出发,聊聊 Go 调度器真正在做什么。
先忘掉 GMP
在解释 GMP 之前,我想先问你一个问题:
如果让你设计一个调度器,要在 4 个 CPU 核心上运行 10000 个任务,你会怎么做?
最简单的方案:搞一个任务队列,4 个线程不断从队列里取任务执行。
var queue = make(chan Task, 10000)
for i := 0; i < 4; i++ {
go func() {
for task := range queue {
task.Run()
}
}()
}
这就是 Go 1.0 的调度器,只不过”任务”叫 goroutine,“线程”叫 M(Machine)。
问题来了:4 个线程抢一个队列,锁竞争严重,性能很差。
加个 P 试试
2012 年 Dmitry Vyukov 的解决方案很简单:给每个线程一个私有队列。
之前:
全局队列 ← M0, M1, M2, M3 (都在抢)
之后:
M0 ← P0 [私有队列]
M1 ← P1 [私有队列]
M2 ← P2 [私有队列]
M3 ← P3 [私有队列]
全局队列(备用)
这个”私有队列的拥有者”就是 P (Processor)。
现在每个 M 只访问自己绑定的 P 的队列,不需要加锁。性能问题解决了。
但新问题来了:如果 P0 有 1000 个任务,P1 没有任务怎么办?
Work Stealing:偷来的负载均衡
解决方案也很直接:空闲的 P 可以从别人那里偷任务。
P0: [G1, G2, G3, G4, G5, G6] ← M0 忙着呢
P1: [] ← M1 没活干
M1: "P0 你任务太多了,分我一半"
P0 → P1: [G4, G5, G6]
这就是 Work Stealing(工作窃取),一个分布式调度的经典算法。
Go 的实现:
- 先看全局队列有没有
- 没有就随机选一个 P,偷它一半的任务
回到那个 Bug:Hand Off
现在回到开头的问题:为什么文件 IO 会导致性能下降?
假设我们有 4 个 P,4 个 M。某个 goroutine 要读一个大文件:
data, err := os.ReadFile("huge.log") // 同步阻塞!
这是个系统调用,M 会被操作系统阻塞。如果 4 个 M 都在等文件 IO,即使 CPU 空闲,也没人执行其他 goroutine。
Go 的解决方案:Hand Off(移交)
M0 阻塞了?
→ P0 和 M0 解绑
→ P0 找个空闲的 M(或者创建一个新的)
→ 新 M 继续跑 P0 队列里的其他 G
所以 M 的数量不是固定的,而是按需创建的。
但问题是:Go 默认最多只能创建 10000 个 M。如果你的代码里有大量阻塞的系统调用,M 可能会被耗尽。
这就是我遇到的那个 Bug。解决方案:
- 改用异步 IO(如
io.Reader接口配合 buffer) - 用 goroutine pool 控制并发数
- 或者增加
GOMAXTHREADS(不推荐)
可视化演示
说了这么多,不如看图。下面是我做的交互式动画,你可以直观看到 GMP 的调度过程:
操作方式:
- 点击上方按钮切换场景
- 右侧面板控制播放
建议重点看 Hand Off 场景,理解 M 阻塞时发生了什么。
几个常见误解
”goroutine 不会阻塞”
错。goroutine 执行系统调用时确实会阻塞,只是 Go 会帮你做 Hand Off,不会阻塞其他 goroutine。
但如果阻塞的太多(比如上万个并发文件读取),还是会出问题。
“GOMAXPROCS 设大点性能就好”
不一定。GOMAXPROCS 控制的是 P 的数量,也就是并行执行 goroutine 的上限。
- CPU 密集型:设成 CPU 核心数最优
- IO 密集型:设大了也没用,瓶颈在 IO
”goroutine 可以无限创建”
理论上可以,每个 goroutine 只占 2KB 栈。但:
- 每个 goroutine 会消耗调度开销
- 过多的 goroutine 会导致频繁切换,反而慢
建议:用 worker pool 或 semaphore 控制并发。
性能调试技巧
当你怀疑是调度问题时,可以用 runtime 包看看发生了什么:
import "runtime"
fmt.Println("G 数量:", runtime.NumGoroutine())
fmt.Println("P 数量:", runtime.GOMAXPROCS(0))
fmt.Println("M 数量:", runtime.NumCPU()) // 注意:没有直接 API 看 M 数量
更强大的是 runtime/trace:
f, _ := os.Create("trace.out")
trace.Start(f)
// ... 你的代码
trace.Stop()
然后用 go tool trace trace.out 可视化分析。
小结
GMP 模型的核心思想:
| 组件 | 角色 | 关键点 |
|---|---|---|
| G | 任务 | 轻量,可以有百万个 |
| M | 执行者 | 按需创建,会阻塞 |
| P | 调度上下文 | 数量固定,持有本地队列 |
三个核心机制:
- 本地队列:减少锁竞争
- Work Stealing:负载均衡
- Hand Off:处理阻塞
理解了这些,你就能解释大部分 Go 并发性能问题了。