上周排查一个线上问题:服务的 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 的实现:

  1. 先看全局队列有没有
  2. 没有就随机选一个 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。解决方案:

  1. 改用异步 IO(如 io.Reader 接口配合 buffer)
  2. 用 goroutine pool 控制并发数
  3. 或者增加 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调度上下文数量固定,持有本地队列

三个核心机制:

  1. 本地队列:减少锁竞争
  2. Work Stealing:负载均衡
  3. Hand Off:处理阻塞

理解了这些,你就能解释大部分 Go 并发性能问题了。


延伸阅读