最近在看 Go 的并发调度,突然意识到——用打工人的工作逻辑来比喻 GMP,一切都变得贼清楚。
Goroutine 就是打工人
想象一下工地上的工人。每个工人有一个任务清单(函数调用栈),要逐个完成任务。在 Go 里,一个 Goroutine 就是一个轻量级的打工人。
但这些工人不会自己站在工地里等着被分配任务。他们需要一个流程:有人负责把他们分配到具体的工作岗位上,有人管理工作的节奏,还要防止工人过多导致资源崩溃。
这套管理体系,就是 GMP。
GMP 三大角色
G = Goroutine(工人)
工人本身,最轻量级的单位。创建成本很低,Go 程序可以轻松创建几十万个 Goroutine。他们都在等着被分配工作。
// 随随便便创建 10 万个打工人
for i := 0; i < 100000; i++ {
go doWork(i)
}
M = Machine(工作岗位 / CPU 核心)
M 对应真实的操作系统线程。一个岗位同一时间只能有一个工人在工作。M 的数量大致受限于 CPU 核心数(或者说 GOMAXPROCS),不会无限增长,这样系统才不会被打崩。
P = Processor(工头/经理)
这是我觉得最关键的一个。P 不是处理器本身,而是"调度权限"和"本地工作队列"的拥有者。
想象 P 就是工地的工头:
- 他有权分配工人到具体的岗位
- 他手里握着一个本地的工作队列(Local Run Queue)
- 多个 P 可以并行工作,不互相干扰
调度是怎么玩的
全局工作队列(Global Run Queue)
↓
┌────────────────────┐
│ P1 (工头1) │
│ 本地队列: [G1,G2] │ ← 关键:有自己的队列
│ 绑定 M1 (岗位1) │
└────────────────────┘
┌────────────────────┐
│ P2 (工头2) │
│ 本地队列: [G3,G4] │
│ 绑定 M2 (岗位2) │
└────────────────────┘
正常流程是这样的:
- 分配阶段:P 从自己的本地队列取一个 G(工人),分配给绑定的 M 去执行
- 工作中:M 在执行 G 的函数代码
- 等等,工人阻塞了:如果 G 被系统调用阻塞(比如磁盘 I/O),这个 M 不能浪费在这儿,必须去干别的。此时 M 会脱离 P,Go 运行时会新建或唤醒另一个 M 来接替 P,继续处理其他 G
- 当前 P 的队列空了:P 会去全局队列或者隔壁 P 那儿"偷工作"(work stealing),防止有的岗位忙死、有的岗位闲死
我理解到的关键点
为什么不直接用 OS 线程?
OS 线程太重。上下文切换开销大,创建/销毁成本高。Goroutine 轻到飞起,所以我们可以创建上万个,而不用担心系统资源炸了。
为什么要有 P?
直接让 G 跟 M 一对一,不行吗?答案是:不行。原因有两个:
- M 数量是有限的(受 CPU 核心数限制),但 G 可以无限创建
- M 可能被 OS 阻塞,此时需要有人来协调——谁来管理这堆 G?答案就是 P
P 才是真正拥有"调度权"的东西。一个 P 可以管理多个 G,分配给当前绑定的 M 去执行。
最关键的理解:不是 G 和 M 直接配对,而是 P 在中间充当调度器的角色。 这样即使 M 被阻塞,P 也能快速切换到新的 M 继续工作。
踩的小坑
一开始我以为 Goroutine 就是"轻量级线程",以为调度完全是自动无感的。其实不完全是:
// 这种 CPU 密集的代码,就没法自动调度了
// Goroutine 会一直占用 M,其他 G 得不到机会
for {
i++
}
所以如果写了纯 CPU 密集的代码,最好手动加个 runtime.Gosched()
来让出执行权。
另外,GOMAXPROCS
这个参数决定了 P 的数量(通常等于 CPU 核心数)。在容器环境或者虚拟机里,要特别注意这个值,否则可能导致 Go 程序只用一个核心。
最后
理解 GMP 的最简单方式就是:把 Goroutine 当工人,把 M 当岗位,把 P 当工头,全局队列当任务池。 工头在中间做调度,工人轮流上岗干活,工地不会因为工人多就崩溃。
这就是为什么 Go 在高并发场景下这么能打的原因
这阵子在准备投简历冲实习了,继续怼 Go 底层吧。😤