GMP 是 Go runtime 真实使用的调度模型。它让大量 goroutine 复用较少的操作系统线程,并在多核机器上并行运行 Go 代码。
理解 GMP 的价值不在于记住某个 runtime 函数的调用顺序,而在于知道 goroutine 何时能并行、遇到阻塞时会发生什么,以及哪些并发问题调度器无法替你解决。
本文描述 Go runtime 的通用设计。运行队列细节、抢占实现和容器中的
GOMAXPROCS默认行为会随 Go 版本和运行平台变化,不应把它们当作稳定的语言契约。
1. GMP 分别是什么
| 组件 | 含义 | 职责 |
|---|---|---|
| G | Goroutine | 执行单元,保存栈、程序计数器和调度状态等上下文。 |
| M | Machine | 操作系统线程,真正获得 CPU 时间片并执行代码。 |
| P | Processor | 执行 Go 用户代码所需的逻辑资源,维护本地可运行 G 等运行时状态。 |
可以把关系概括为:M 需要持有 P 才能执行 Go 代码,P 再从可运行的 G 中选择一个交给 M。
GOMAXPROCS 决定 P 的数量,因此它限制的是同一时刻可以并行执行 Go 代码的数量,而不是 goroutine 的总数,也不是操作系统线程的总数。M 的数量可以因为阻塞系统调用、cgo 或 runtime 的调度需要而多于 P。
2. 为什么 Go 需要 P
如果所有 M 都从一个全局 goroutine 队列取任务,高并发下会频繁竞争同一把锁。P 将大部分调度状态分散到本地:一个正在工作的 M 通常从所持有 P 的本地队列取 G,减少共享状态竞争。
P 还承载部分与调度和内存分配有关的本地运行时状态。具体字段会随 Go 版本演进,但核心作用不变:让调度与部分运行时资源能够按 P 分片管理。
这不意味着本地队列“永远无锁”。所属 M 取任务的路径很轻量,但其他 P 偷取任务、全局队列交互等场景仍需要原子操作或同步。
3. G 的主要状态
日常分析不需要记住全部内部枚举,但以下状态足以解释绝大多数现象:
_Grunnable:已准备运行,正在某个运行队列中等待。_Grunning:正在某个 M 上执行。_Gwaiting:等待 channel、定时器、互斥锁或其他同步事件。_Gsyscall:处于系统调用相关状态,runtime 会尝试避免让对应 P 长时间闲置。_Gdead:goroutine 已退出,其运行时对象可被后续复用。
新 goroutine 的关键路径是从可复用的已结束状态变为 _Grunnable,而不是“创建后处于 _Gidle”。当 channel 收到数据、定时器到期或锁被释放时,等待中的 G 会重新变为 _Grunnable。
4. 调度器怎样寻找工作
下面是概念上的主路径,而不是某个 Go 版本的逐行源码顺序:
- 优先尝试当前 P 的本地可运行 G,其中包含用于降低延迟的
runnext槽位。 - 在适当时机从全局可运行队列获取一批任务,避免全局任务长期得不到执行。
- 处理网络轮询已经就绪的 I/O 任务。
- 当前 P 没有工作时,尝试从其他 P 偷取一批可运行 G。
- 仍然没有工作时,M 可以休眠,直到新任务、网络事件或定时器将其唤醒。
本地队列提高局部性,全局队列保证整体公平性,work-stealing 则用于平衡负载。偷取通常会转移一批任务而不是只拿一个,以减少反复偷取造成的开销;具体批量大小与策略属于实现细节。
5. 阻塞时发生什么
5.1 channel、锁和定时器等待
这类等待通常不会阻塞 M。G 转为等待状态后,M 可以继续从 P 的队列执行其他可运行 G:
ch := make(chan struct{})
go func() {
<-ch // 当前 G 等待;其他可运行 G 仍可被调度
}()
ch <- struct{}{}
5.2 系统调用与 cgo
当 G 进入可能长时间阻塞的系统调用时,runtime 会尽力让 P 与原 M 分离,再由其他可用 M 承接 P 上的工作。系统调用返回时,原 G 会尝试恢复执行;若不能快速拿到 P,也可能重新进入可运行队列。
这是“阻塞尽量不传染”的设计,不是绝对保证。大量阻塞 syscall、长时间 cgo 调用、文件系统或网络资源耗尽仍会消耗线程和系统资源,并影响整体延迟。
6. 抢占与公平性
早期 Go 更依赖函数调用等安全点进行协作式抢占。Go 1.14 起,在支持的平台上 runtime 引入了基于信号的异步抢占能力,使长时间计算的 goroutine 更有机会让出执行权。
不要把它理解成“任何一行代码都会立即被打断”。垃圾回收、原生代码、临界区和不同平台都会影响实际响应时间。对 CPU 密集型任务,仍应主动拆分工作、控制并发,并在需要时检查取消信号。
7. 对业务代码的实际影响
7.1 并发不等于并行
创建一万个 goroutine 表示有一万个独立执行单元;只有获得 P 的 goroutine 才能同时在多个 CPU 上执行。CPU 密集型任务的有效并行度通常应与可用 CPU 和下游资源能力相匹配。
7.2 goroutine 很轻,但不是免费
goroutine 的初始栈很小,并会按需增长,因此它比直接为每个任务创建 OS 线程便宜得多。但每个 G 仍有栈、运行时元数据、闭包引用和业务对象。无边界创建 goroutine 仍会导致内存增长、调度开销和下游过载。
对不受控输入、批量任务和外部 RPC,应使用 worker pool、信号量或有界队列限制并发度。
7.3 GMP 不会消除数据竞争
调度器只决定何时运行 G,不会让共享内存自动安全。共享状态仍需使用 sync.Mutex、sync.RWMutex、sync/atomic 或经过明确设计的 channel 通信。上线前应运行:
go test -race ./...
7.4 优先观测,再调 GOMAXPROCS
大多数服务不应凭直觉频繁修改 GOMAXPROCS。先通过 CPU 使用率、运行时指标、pprof 和 trace 确认瓶颈是 CPU、锁竞争、goroutine 泄漏、阻塞 I/O 还是下游限流,再在接近生产的负载下验证调整效果。
8. 常见误区
| 误区 | 正确理解 |
|---|---|
| M 的数量等于 P 的数量 | P 限制并行执行 Go 代码;M 可因阻塞或 runtime 需要多于 P。 |
| 一个 goroutine 阻塞会拖住整个 Go 程序 | channel 等等待通常只挂起当前 G;系统调用也有让 P 继续工作的机制,但资源耗尽仍会造成影响。 |
| 10 万 goroutine 一定没问题 | 是否可承受取决于栈增长、持有对象、调度开销和下游容量。 |
GOMAXPROCS 越大越快 |
超过可用 CPU 或下游能承受的并发度,可能只会增加上下文切换与争用。 |
| 用 channel 就不会有并发问题 | channel 需要明确所有权和关闭协议;共享状态仍可能发生竞态和死锁。 |
9. 总结
GMP 的核心不是神秘的“三个字母”,而是把大量轻量级 G 映射到有限的 M,并用 P 管理可并行执行 Go 代码的资源与本地调度状态。
对开发者而言,最重要的实践是:控制并发度、避免 goroutine 泄漏、正确处理共享状态,并依赖观测数据而不是猜测来判断调度和性能问题。