Go GMP 调度模型原理分析

Go 程序可以创建大量 goroutine,但操作系统真正调度的是线程。Go runtime 在两者之间增加了调度层:用 G 表示 goroutine,用 M 表示操作系统线程,用 P 保存执行 Go 代码所需的调度与分配资源。调度器的核心任务,就是把可运行的 G、能够执行的 M 和持有执行资格的 P 组合起来。^[来源:Go Runtime Hacking - Scheduler structures]

本文依据 2026-07-27 查询到的 Go 官方运行时源码。GMP 的核心抽象相对稳定,但队列策略、抢占条件和函数实现属于 runtime 内部细节,未来版本可能变化。

一、G、M、P 分别是什么

结构含义主要职责
GGoroutine,对应 runtime 的 g保存栈、指令位置、状态、等待原因和所属 M 等执行上下文
MMachine,对应 runtime 的 m对操作系统线程的抽象,实际执行 Go 代码、runtime 代码或系统调用
PProcessor,对应 runtime 的 p保存执行 Go 代码所需的资源,例如本地运行队列和分配器缓存

官方文档对三者关系的约束很明确:M 想执行用户 Go 代码,必须关联一个 P;M 可以在没有 P 的情况下阻塞在系统调用中;P 的数量恰好等于 GOMAXPROCS。M 的数量则可能更多,因为多个线程可以同时阻塞在系统调用里。^[来源:Go Runtime Hacking - Gs, Ms, Ps]

可以把运行关系简化为:

可运行的 G ──进入运行队列──> P ──绑定──> M ──映射──> OS Thread

                              └── 调度器状态、本地队列、内存分配缓存

P 不是 CPU,也不是线程。把 P 理解为“执行 Go 代码的许可证和本地资源集合”更准确。GOMAXPROCS=4 表示最多有 4 个 P 可以并行执行用户 Go 代码,不代表程序只会创建 4 个 M,更不代表只能存在 4 个 G。^[来源:Go Runtime Hacking - Gs, Ms, Ps]

二、从 go f() 到可运行 G

编译器会把 go f() 转换为对 runtime newproc 的调用。newproc 通过 newproc1 创建或复用一个 g,准备初始栈和执行入口,把状态设置为 _Grunnable,再调用 runqput 将它放入当前 P 的运行队列;必要时通过 wakep 唤醒工作线程。^[来源:runtime/proc.go - newproc]

go f()

runtime.newproc

runtime.newproc1:创建或复用 G,准备栈和入口

runtime.runqput:进入当前 P 的本地队列

runtime.wakep:需要时唤醒 M

这说明 go f() 返回时,通常只是把新 G 变成“可运行”,并不保证它已经执行,更不保证它在哪个线程、哪个 P 或什么时刻运行。

三、本地队列、全局队列与 runnext

每个 P 都有自己的本地运行队列。当前源码中,p.runq 是长度为 256 的环形队列,另外还有一个 runnext 槽位。runqput(..., next=true) 会优先尝试把 G 放入 runnext;被选中的 G 可以继承当前 G 剩余的调度时间。^[来源:runtime/runtime2.go - type p] ^[来源:runtime/proc.go - runqput]

如果本地队列已满,runqputslow 会把本地队列中的一批 G 连同新 G 转移到全局运行队列。全局队列由调度器共享,可在某个 P 缺少本地工作时补充任务。^[来源:runtime/proc.go - runqputslow 与 globrunqputbatch]

调度并不是简单地“永远先本地、再全局”。当前 findRunnable 会综合 GC 工作、定时器、本地队列、全局队列、网络轮询和 work stealing;为了避免本地 goroutine 相互唤醒导致全局队列饥饿,当前实现还会周期性检查全局队列。^[来源:runtime/proc.go - findRunnable]

四、Work Stealing 如何平衡负载

假设 P0 的本地队列堆积了很多 G,而 P1 已经没有工作。如果 P1 直接休眠,会浪费可用并行度。Go 调度器会让缺少工作的 P 尝试从其他 P 的运行队列窃取任务,这就是 work stealing。^[来源:runtime/proc.go - stealWork]

P0 本地队列:[G1 G2 G3 G4 G5 G6]
P1 本地队列:[]
 
P1 尝试从 P0 窃取一批 G
 
P0:[G1 G2 G3]
P1:[G4 G5 G6]

示意图表达的是负载转移,不代表源码保证每次都精确平分。当前实现会随机选择目标 P,并进行有限次数的窃取尝试,还会结合定时器和空闲 P 状态做判断。

五、G 阻塞时,M 和 P 会怎样

1. Go 同步原语阻塞

当 G 因 channel、mutex、条件变量或 timer 等 Go runtime 可管理的原因等待时,runtime 会把 G 从运行状态切换到等待状态并停放。原来的 M 和 P 可以继续运行其他可运行 G。因此,“一个 goroutine 阻塞”通常不等于“一个操作系统线程必须跟着阻塞”。

2. 网络 I/O 阻塞

对于可由 runtime 网络轮询器管理的文件描述符,G 会通过 gopark 等待 I/O;底层 poller 收到读写就绪、超时或关闭事件后,把相关 G 重新变为可运行。调度器的 findRunnable 也会轮询网络事件,并把就绪 G 注入运行队列。^[来源:runtime/netpoll.go] ^[来源:runtime/proc.go - findRunnable]

这让大量网络连接不必一一长期占用等量的 Go 执行线程,但前提是 I/O 路径能够进入 runtime 的网络轮询机制;普通阻塞系统调用不能一概视为 netpoll。

3. 阻塞系统调用

G 进入系统调用时会进入 _Gsyscall 相关状态。为了避免 P 随阻塞线程一起闲置,runtime 可以让该 M 交出 P,其他 M 再取得这个 P 继续运行 Go 代码。系统调用返回后,原 M 会尝试重新取得旧 P 或空闲 P;如果无法取得,G 会回到可运行状态等待调度。^[来源:runtime/proc.go - entersyscall 与 exitsyscall] ^[来源:Go Runtime Hacking - Gs, Ms, Ps]

六、抢占为什么必要

如果一个 G 长时间执行计算且不主动阻塞,其他 G 仍然需要获得运行机会,GC 也需要在安全点检查它。当前 runtime 把安全点分为三类:G 已阻塞或进入系统调用时的 blocked safe-point、运行代码主动检查抢占请求时的 synchronous safe-point,以及可以安全暂停用户代码的 asynchronous safe-point。^[来源:runtime/preempt.go]

同步抢占会借助函数序言中的栈边界检查处理请求;异步抢占可以通过操作系统机制暂停线程,确认当前位置安全后,把执行上下文调整到 asyncPreempt,再进入调度器。并不是任意一条指令都一定可以被异步抢占,runtime、汇编和某些敏感区域存在限制。^[来源:runtime/preempt.go]

当前 proc.goforcePreemptNS 为 10ms,sysmon 会协助发现长时间运行的 G,并处理陷在系统调用中的 P。但 10ms 是当前实现的抢占判断阈值,不是语言规范承诺的固定时间片,也不能用来推导 goroutine 的精确执行顺序。^[来源:runtime/proc.go - sysmon 与 retake]

七、一次调度过程示例

假设 GOMAXPROCS=2,程序创建 G1、G2、G3:

  1. runtime 建立两个 P,例如 P0、P1。
  2. 主 goroutine 在 P0 上创建 G1、G2、G3,它们通常先进入 P0 的本地队列。
  3. runtime 唤醒或创建另一个 M,并让它取得 P1。
  4. P1 没有本地工作,于是可能从 P0 窃取一批 G。
  5. G1 因 channel 等待被停放,绑定 P 的 M 继续选择其他 G。
  6. G2 等待网络 I/O,netpoll 在事件就绪后使其重新可运行。
  7. G3 进入阻塞系统调用,原 M 可能与 P 分离,P 被其他 M 接管。
  8. G 执行结束后进入退出流程,其 g 对象可进入空闲池供以后复用。^[来源:runtime/proc.go - newproc、findRunnable、goexit0]

这只是帮助理解的可能路径。Go 不保证不同 goroutine 的调度顺序;不要依赖“先创建就先执行”或“同一 G 永远留在同一线程”。

可以用下面的最小程序制造多于 P 数量的可运行 G:

package main
 
import (
	"fmt"
	"runtime"
	"sync"
)
 
func main() {
	runtime.GOMAXPROCS(2)
	var wg sync.WaitGroup
	for id := 0; id < 8; id++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			var sum uint64
			for i := 0; i < 50_000_000; i++ {
				sum += uint64(i)
			}
			fmt.Println(id, sum)
		}(id)
	}
	wg.Wait()
}

运行结果的打印顺序不是调度协议;应结合 scheduler trace 或 execution trace,观察两个 P 如何处理八个计算任务。

八、如何观察调度器

可以先运行一个包含并发任务的程序,再启用 scheduler trace:

GODEBUG=schedtrace=1000,scheddetail=1 go run main.go

重点观察 gomaxprocsidleprocsthreadsidlethreads、全局 runqueue 和各 P 的本地队列长度。它适合判断并行度不足、线程过多和负载不均,但最终 CPU 利用率仍应结合操作系统指标确认。^[来源:Go Wiki - Scheduler Trace]

需要查看一段时间内的调度、系统调用、网络等待和 GC 事件时,可以使用 execution tracer:

go test -trace trace.out ./...
go tool trace trace.out

执行 trace 更适合观察 goroutine 如何运行、何时等待以及 CPU 是否被充分利用;定位 CPU 热点则通常先使用 CPU profile。^[来源:Go Diagnostics]

九、常见误解

  • G 等于线程:错误。G 是 goroutine 的 runtime 表示,M 才对应操作系统线程。
  • P 等于 CPU 核:不准确。P 是执行 Go 代码所需的 runtime 资源;数量由 GOMAXPROCS 控制。
  • M 必须始终绑定 P:错误。M 可以不带 P 阻塞在系统调用或处于空闲状态。
  • goroutine 阻塞一定会阻塞线程:错误。channel、timer 和可轮询网络 I/O 等路径通常会停放 G,让 M/P 继续工作。
  • work stealing 每次偷一半:这是常见简化图,不是所有调度路径的固定承诺。
  • 每个 G 固定运行 10ms:错误。10ms 是当前实现中的强制抢占判断阈值,不是精确时间片或语言规范。
  • goroutine 按创建顺序执行:错误。调度顺序受队列、唤醒、抢占、系统调用、网络事件和运行负载影响。

十、实践意义

理解 GMP 的主要价值不是手工控制调度器,而是建立正确的性能判断:CPU 密集任务是否能提供足够并行工作,阻塞系统调用是否造成线程膨胀,网络任务是否走 netpoll,goroutine 是否大量停在同步点,以及 GOMAXPROCS 是否与运行环境匹配。

排查问题时,应从可观测证据开始:goroutine dump 看当前等待位置,scheduler trace 看队列与线程概况,execution trace 看一段时间内的调度事件,CPU profile 看计算热点。不要只根据 goroutine 数量或一张 GMP 示意图推断瓶颈。

开放问题

  • 不同 Go 版本对默认 GOMAXPROCS、容器配额和动态更新策略有什么变化?
  • cgo 和不可轮询文件 I/O 如何影响 M 的数量?
  • GC worker、timer 和普通 G 如何共同参与 findRunnable
  • 如何从 execution trace 中识别 work stealing、系统调用阻塞和调度延迟?

这些问题适合在后续获得新的官方资料或实验结果时,继续拆分为独立知识页面。