理解 Go 的 GMP 调度模型,最容易走进的误区是先背诵“G 是 goroutine、M 是线程、P 是处理器”,然后把 runtime 中几百个字段逐一翻译。字段越读越多,三个对象为什么缺一不可反而越来越模糊。
本文换一种方式:以 Go 1.24.13 的 runtime2.go 为准,只挑选 g、m、p 中能回答调度核心问题的字段:
- G 的执行现场保存在哪里,为什么可以换一个 M 继续运行?
- M 为什么必须取得 P 才能执行普通 Go 代码?
- P 如何组织待运行的 G,又如何限制并行度?
- G 阻塞、进入系统调用和被抢占时,三者如何重新组合?
先给出全文的核心结论:
G: task and resumable execution context
M: operating-system thread that executes instructions
P: scheduler resources required to execute Go code
Running Go code = one G + one M + one P
G、M、P 并非永久绑定。正在运行的 G 暂停后,可以由另一个持有 P 的 M 恢复;M 因系统调用阻塞时,它原先持有的 P 也可以转交给另一个 M。
G:可以暂停和恢复的执行任务
g 定义在 runtime/runtime2.go。完整结构体很大,与调度主线最相关的字段可以缩减为:
type g struct {
stack stack
stackguard0 uintptr
m *m
sched gobuf
syscallsp uintptr
syscallpc uintptr
syscallbp uintptr
atomicstatus atomic.Uint32
goid uint64
schedlink guintptr
waitsince int64
waitreason waitReason
preempt bool
preemptStop bool
lockedm muintptr
}
stack:栈属于 G,而不是 M
type stack struct {
lo uintptr
hi uintptr
}
stack 描述 goroutine 的栈区间 [lo, hi)。每个 G 保存自己的函数调用帧、局部变量和返回地址。M0 暂停 G1 后,即使随后由 M1 恢复 G1,使用的仍然是 G1.stack。
Time 1: M0 + P0 executes G1 with G1.stack
Time 2: G1 is suspended
Time 3: M1 + P1 resumes G1 with G1.stack
这解释了 G 为什么不是 M 上的一段固定任务:执行它的线程可以改变,但栈和执行状态始终跟随 G。
stackguard0:把扩栈检查复用为抢占入口
编译器通常会在函数入口插入栈空间检查。正常情况下,stackguard0 位于栈低地址附近:
gp.stackguard0 = gp.stack.lo + stackGuard
当剩余空间不足时,检查会进入 morestack,由 runtime 分配更大的栈并复制原栈。runtime 请求协作式抢占时,则会把它设为哨兵值:
gp.preempt = true
gp.stackguard0 = stackPreempt
G 再次执行到栈检查点时便会进入 runtime。于是同一个字段承担了两种职责:
normal guard address -> check whether the stack must grow
stackPreempt -> enter runtime for preemption
preempt == true 表示已经发出抢占请求,不代表 G 已经停止;只有状态切换完成后,G 才真正让出 M 和 P。
sched:G 能够迁移的关键
type gobuf struct {
sp uintptr
pc uintptr
g guintptr
ctxt unsafe.Pointer
ret uintptr
lr uintptr
bp uintptr
}
sched 保存恢复 G 所需的执行现场,其中最重要的是:
sp:恢复哪一个栈位置;pc:从哪一条指令继续;g:这份现场属于哪个 G;bp、lr:部分架构恢复调用帧和返回地址需要的状态;ctxt:新 G 启动或特殊 runtime 切换所需的上下文。
G 进入调度器时,架构相关汇编把现场写入 G.sched,然后切到当前 M 的 g0 栈。调度器重新选择这个 G 后,gogo(&gp.sched) 恢复现场并跳回保存的指令位置。
值得注意的是,gobuf 中没有 M 和 P:
saved in gobuf: G, SP, PC, BP, LR, context
not saved in gobuf: original M, original P
恢复 G 只需要它自己的栈和寄存器现场,不要求回到原来的 M 或 P。这正是 G 可以在多个系统线程之间迁移的源码依据。
m:G 与线程只是当前关系
m *m
当 G 正在运行时,双方互相引用:
G.m -> M
M.curg -> G
这表示“当前由谁执行”,而不是永久归属。G 阻塞并再次被调度后,g.m 可以指向另一个 M。只有 runtime.LockOSThread 等特殊场景才会通过 G.lockedm 和 M.lockedg 建立线程绑定。
atomicstatus:状态也是执行权
atomicstatus atomic.Uint32
理解调度主线只需先掌握几个常见状态:
_Grunnable ready to run and waiting in a run queue
_Grunning executing on an M with a P
_Gwaiting blocked on channel, lock, timer, netpoll, and so on
_Gsyscall executing a system call
_Gpreempted stopped by runtime preemption
_Gdead no current task and available for reuse
状态切换由 runtime 使用原子操作协调,例如:
casgstatus(gp, _Grunnable, _Grunning)
这不只是把一个整数从 A 改成 B。CAS 成功也意味着当前 M 确认 G 仍可运行,并取得了执行它的权利。GC 扫栈、抢占和其他调度路径都可能观察 G,因此状态不能靠普通赋值随意修改。
waitreason 与 schedlink:为什么等、在哪里等
atomicstatus == _Gwaiting 只能说明 G 当前不能运行,waitreason 才说明它是在等待 channel、锁、定时器还是网络 I/O。goroutine dump 中的 [chan receive]、[sleep] 和 [IO wait] 等信息就与这些等待原因有关。
waitsince int64
waitreason waitReason
schedlink 则用于把 G 链接进 runtime 的调度链表。P 的本地运行队列使用数组,而全局运行队列等 gQueue 结构通过 G 内部的链接字段组织元素。它提醒我们:一个 _Grunnable 的 G 不等于正在运行,它还必须位于某个可被调度器发现的位置。
syscallsp 与 syscallpc:系统调用中的 G 仍需被追踪
syscallsp uintptr
syscallpc uintptr
syscallbp uintptr
G 进入系统调用后,M 可能阻塞在内核里。runtime 保存系统调用现场,使 GC、栈回溯和系统调用返回路径仍能正确处理这个 G。
更重要的是,进入系统调用的 M 可以失去 P:M 留在内核中,P 被交给其他 M 执行队列里的 G。这解释了为什么 M 的数量可以大于 P,也解释了为什么阻塞一个系统线程不必同时损失一份 Go 并行执行能力。
M:真正执行指令的系统线程
M 对应操作系统线程。m 中最能帮助理解 GMP 的字段是:
type m struct {
g0 *g
gsignal *g
curg *g
p puintptr
nextp puintptr
oldp puintptr
spinning bool
blocked bool
park note
lockedg guintptr
preemptoff string
preemptGen atomic.Uint32
signalPending atomic.Uint32
}
curg:M 当前执行的用户 G
curg *g
普通 Go 代码运行时:
M.curg = G
G.m = M
一个 M 同一时刻最多执行一个普通 G,但会在生命周期中先后执行许多 G;一个 G 暂停后也可能换到其他 M。因此 G 和 M 从整个运行过程看是多对多,而某一执行时刻是一对一。
g0:调度器使用的系统栈
g0 *g
每个 M 都有一个特殊 G,即 g0。它不执行用户的 goroutine 函数,而是为调度、栈管理和部分 GC/runtime 工作提供稳定的系统栈。
User G
|
| save context into G.sched
v
M.g0
|
| schedule -> findRunnable -> execute
v
Next User G
这里容易产生一个误解:g0 虽然也使用 g 结构表示,却不是 P 本地队列中的普通业务任务。它与 M 一一关联,是 M 进入 runtime 底层调度逻辑时的执行环境。
p、nextp 与 oldp:P 可以在 M 之间交接
p puintptr
nextp puintptr
oldp puintptr
p:M 当前持有的 P;没有 P 时不能执行普通 Go 代码;nextp:M 被唤醒或启动后准备接管的 P;oldp:M 进入系统调用之前持有的 P。
这些字段把 P 的交接过程直接写进了 M:
M0.p = P0
|
| M0 enters a blocking syscall
v
M0.oldp = P0, M0.p = nil
|
| P0 is handed off
v
M1.nextp = P0
|
v
M1.p = P0
M0 从系统调用返回时会尝试重新取得 oldp 或其他空闲 P。如果失败,原 G 会回到可运行队列,M0 则不能直接继续执行它。
因此,P 与 M 在某一时刻最多一对一,但不是永久绑定;还可以存在没有 P 的 M,例如阻塞在系统调用中的线程。
spinning、blocked 与 park:没工作时不必立即创建线程
spinning bool
blocked bool
park note
没有本地任务的 M 会尝试从全局队列、netpoll 或其他 P 的本地队列寻找工作。spinning 表示它正在主动寻找工作,而不是已经休眠;如果最终仍找不到工作,M 可以通过 park 等待唤醒,并以 blocked 反映阻塞状态。
no local G
|
v
spinning: search global queue, netpoll, and other Ps
|
+-- found work -> execute G
|
+-- no work -> release P and park M
自旋 M 在“立即休眠带来的唤醒延迟”和“持续空转浪费 CPU”之间做平衡。wakep 也不会为每个新 G 无条件创建一个线程,而是结合空闲 P 和自旋 M 数量决定是否启动或唤醒 M。
preemptoff 与抢占字段:有些代码区间不能随意停止
preemptoff string
preemptGen atomic.Uint32
signalPending atomic.Uint32
preemptoff 非空时,当前 M 上的 G 暂时不应被抢占,通常因为 runtime 正处在必须保持状态一致的关键区间。signalPending 和 preemptGen 则用于异步抢占信号的发送与完成确认。
这说明异步抢占并不是在任意机器指令上粗暴切走 G。runtime 必须判断当前执行位置是否安全,并协调发出请求、信号到达以及抢占是否真正完成。
P:执行许可、运行队列与本地资源
P 不是物理 CPU,也不是系统线程。它是 M 执行普通 Go 代码时必须持有的调度上下文。p 中最值得关注的字段是:
type p struct {
id int32
status uint32
m muintptr
schedtick uint32
syscalltick uint32
sysmontick sysmontick
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr
mcache *mcache
gFree struct {
gList
n int32
}
timers timers
}
status 与 m:P 是可转移的执行许可
status uint32
m muintptr
P 可以处于 _Pidle、_Prunning、_Psyscall、_Pgcstop 等状态。m 反向指向当前关联的 M,P 空闲时为 nil。
GOMAXPROCS 控制的是 P 的数量,因此限制的是同一时刻执行普通 Go 代码的 M 数量,而不是 G 或系统线程总数:
GOMAXPROCS = 4
P count = 4
maximum running Go Gs = 4
M count may be greater than 4
G count may be much greater than 4
从这个角度看,把 P 称为“逻辑 CPU”有助于入门,但不够准确;P 更像一份包含运行队列和本地缓存的 Go 执行许可。
runq:把大多数调度留在 P 本地
runqhead uint32
runqtail uint32
runq [256]guintptr
每个 P 有一个长度为 256 的本地可运行队列。当前 G 创建或唤醒另一个 G 时,runtime 通常优先把新 G 放到当前 P,而不是争用全局队列锁。
队列满时,runqputslow 会把一批 G 转移到全局运行队列;当前 P 没有任务时,findRunnable 还可以通过 runqsteal 从其他 P 偷取一部分 G。这两个方向共同实现负载均衡:
local runq full -> move a batch to global runq
local runq empty -> steal a batch from another P
因此,G 与 P 没有永久归属。G 可能先进入 P0 的本地队列,随后被 P1 偷取并在 M1 上运行。
runnext:优先候选,不是绝对的下一个
runnext guintptr
runnext 保存一个希望优先运行的 G。当前 G 唤醒与自己有直接协作关系的 G 时,后者可以继承当前时间片的剩余部分,减少通信双方的调度延迟。
但 runnext 不是严格的优先级保证:其他 P 可以原子地偷走它,GC、安全点和调度公平性逻辑也可能改变实际执行顺序。更准确的理解是“当前 P 的优先候选”。
schedtick 与 sysmontick:调度公平和抢占需要时间线索
schedtick uint32
syscalltick uint32
sysmontick sysmontick
schedtick 随调度轮次推进,调度器会周期性检查全局运行队列,避免持续产生本地任务的 P 让全局 G 长期饥饿。syscalltick 和 sysmontick 则帮助 sysmon 判断 P 的调度和系统调用进展,包括是否需要抢占运行过久的 G、重新取得陷入系统调用的 P。
这些字段说明 Go 调度不是简单地永远从本地队列取队首元素,它还必须处理公平性、系统调用和长时间运行任务。
mcache、gFree 与 timers:为什么调度资源放在 P 上
mcache *mcache
gFree struct { ... }
timers timers
mcache:当前执行线程进行小对象分配时使用的每 P 缓存;gFree:缓存状态为_Gdead的 G,创建 goroutine 时可以复用;timers:与该 P 相关的定时器状态,调度器寻找任务时也会检查到期定时器。
它们解释了 P 为什么不只是一个整数令牌。把高频资源放在 P 上,持有 P 的 M 就能在许多热路径中减少全局锁竞争。M 更换后,新 M 接管同一个 P,也同时接管这些调度和分配资源。
用三个过程串起字段
孤立地记字段仍然容易遗忘。下面通过三个过程观察它们如何协作。
创建并运行一个 G
go f() 进入 newproc、newproc1 后,runtime 初始化 G 的栈和 sched,把状态改为 _Grunnable,再通过 runqput 提交到当前 P:
newproc1
|
+-- initialize G.stack
+-- construct G.sched
+-- set G.atomicstatus = _Grunnable
v
P.runq or P.runnext
|
v
M with P calls findRunnable
|
+-- CAS G: _Grunnable -> _Grunning
+-- M.curg = G
+-- G.m = M
v
gogo(&G.sched)
这里形成一次普通 Go 代码执行所需的完整组合:一个可恢复的 G、一个真正执行指令的 M,以及一个提供运行队列和执行许可的 P。
G 因 channel 阻塞
G 在 channel 上不能继续时,runtime 通过 gopark 保存 G.sched,记录 waitreason,把状态切换为 _Gwaiting,然后在同一个 M + P 上寻找其他 G:
G1 + M0 + P0
|
| channel operation blocks
v
save G1.sched
G1.atomicstatus = _Gwaiting
G1.waitreason = channel wait
|
v
M0.g0 runs schedule
|
v
M0 + P0 executes G2
此时只阻塞了 G1,没有阻塞 M0,也没有丢失 P0。channel 条件满足后,ready 把 G1 改回 _Grunnable 并放回运行队列,它以后可能由另一个 M 和 P 恢复。
G 进入阻塞系统调用
系统调用可能把 M 真正阻塞在内核里,因此处理方式不同:
G1 + M0 + P0
|
| entersyscall
v
G1.atomicstatus = _Gsyscall
save G1.syscallsp and G1.syscallpc
M0.oldp = P0
M0.p = nil
|
| handoff P0
v
M1.nextp = P0
M1.p = P0
|
v
M1 + P0 executes another G
系统调用返回后,M0 尝试重新取得 P。取得成功才能直接继续执行 G1;否则 G1 变回 _Grunnable 并进入全局运行队列。
这条路径集中体现了三个结构体的分工:G 保存任务和系统调用现场,M 承担内核阻塞,P 则可以脱离被阻塞的 M,继续提供 Go 代码执行能力。
三个结构体之间到底是什么关系
从某一个执行瞬间看:
one running G <-> one M <-> one P
但从整个生命周期看:
- G 与 M 是多对多:M 先后执行多个 G,G 也可以迁移到不同 M;
- G 与 P 是多对多:G 可以进入不同 P 的队列并在不同 P 上执行;
- M 与 P 是动态一对一:某一时刻双方最多互相持有一个,但可以重新组合,也可以存在没有 P 的 M。
通常说 Go 是 M:N 调度,描述的是大量用户态 G 复用较少的操作系统线程 M。P 不是 M:N 的第三端,而是 runtime 在两者之间引入的执行许可和调度资源。
最后,可以用三个结构体中最关键的字段压缩整个 GMP:
| 结构体 | 核心字段 | 回答的问题 |
|---|---|---|
g | stack、sched | G 的数据在哪里,暂停后从哪里恢复? |
g | atomicstatus、waitreason | G 当前能否执行,为什么不能执行? |
g | m、syscallsp、preempt | G 当前由谁执行,如何处理系统调用和抢占? |
m | curg、g0 | M 当前执行谁,在哪里运行调度器? |
m | p、nextp、oldp | M 如何取得、交出和重新取得 P? |
m | spinning、park | 没有工作时,M 如何寻找任务或休眠? |
p | status、m | P 当前是否可以执行 Go 代码,由哪个 M 持有? |
p | runq、runnext | 可运行 G 放在哪里,如何被选择? |
p | schedtick、sysmontick | 如何观察调度进度并维持公平与抢占? |
p | mcache、gFree、timers | 为什么 P 是一组本地资源,而不只是数量限制? |
记住字段的目的不是背结构体,而是建立下面这条因果链:
G owns its stack and saved context
|
v
P makes the runnable G discoverable
|
v
M obtains P and restores G.sched
|
v
G runs until it blocks, exits, or is preempted
只要这条链路清楚,再阅读 newproc、schedule、findRunnable、execute、gopark、entersyscall 和 exitsyscall 等函数时,就能知道每次字段修改究竟在转移任务状态、线程承载关系,还是 P 的执行许可。