Skip to content
Go back

从 g、m、p 三个结构体理解 Go GMP 调度模型

Edit page

理解 Go 的 GMP 调度模型,最容易走进的误区是先背诵“G 是 goroutine、M 是线程、P 是处理器”,然后把 runtime 中几百个字段逐一翻译。字段越读越多,三个对象为什么缺一不可反而越来越模糊。

本文换一种方式:以 Go 1.24.13runtime2.go 为准,只挑选 gmp 中能回答调度核心问题的字段:

  • 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;
  • bplr:部分架构恢复调用帧和返回地址需要的状态;
  • 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.lockedmM.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,因此状态不能靠普通赋值随意修改。

waitreasonschedlink:为什么等、在哪里等

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 不等于正在运行,它还必须位于某个可被调度器发现的位置。

syscallspsyscallpc:系统调用中的 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 底层调度逻辑时的执行环境。

pnextpoldp: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,例如阻塞在系统调用中的线程。

spinningblockedpark:没工作时不必立即创建线程

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 正处在必须保持状态一致的关键区间。signalPendingpreemptGen 则用于异步抢占信号的发送与完成确认。

这说明异步抢占并不是在任意机器指令上粗暴切走 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
}

statusm: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 的优先候选”。

schedticksysmontick:调度公平和抢占需要时间线索

schedtick   uint32
syscalltick uint32
sysmontick  sysmontick

schedtick 随调度轮次推进,调度器会周期性检查全局运行队列,避免持续产生本地任务的 P 让全局 G 长期饥饿。syscallticksysmontick 则帮助 sysmon 判断 P 的调度和系统调用进展,包括是否需要抢占运行过久的 G、重新取得陷入系统调用的 P。

这些字段说明 Go 调度不是简单地永远从本地队列取队首元素,它还必须处理公平性、系统调用和长时间运行任务。

mcachegFreetimers:为什么调度资源放在 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() 进入 newprocnewproc1 后,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:

结构体核心字段回答的问题
gstackschedG 的数据在哪里,暂停后从哪里恢复?
gatomicstatuswaitreasonG 当前能否执行,为什么不能执行?
gmsyscallsppreemptG 当前由谁执行,如何处理系统调用和抢占?
mcurgg0M 当前执行谁,在哪里运行调度器?
mpnextpoldpM 如何取得、交出和重新取得 P?
mspinningpark没有工作时,M 如何寻找任务或休眠?
pstatusmP 当前是否可以执行 Go 代码,由哪个 M 持有?
prunqrunnext可运行 G 放在哪里,如何被选择?
pschedticksysmontick如何观察调度进度并维持公平与抢占?
pmcachegFreetimers为什么 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

只要这条链路清楚,再阅读 newprocschedulefindRunnableexecutegoparkentersyscallexitsyscall 等函数时,就能知道每次字段修改究竟在转移任务状态、线程承载关系,还是 P 的执行许可。

参考资料


Edit page