Go GMP 模型
GMP 是 Go 调度器的三个核心组件:
- G(Goroutine,协程):
go func()创建的任务。 - M(Machine,机器):操作系统线程。
- P(Processor,处理器):调度上下文,管理 G 队列。
M 就是内核级线程,由 runtime 通过 newm/newosproc(创建 M、创建线程的函数)调用系统接口创建,再交给 OS 调度到 CPU 上执行。M 是执行 Go 代码的最小单位,没有 P 的 M 无法执行 Go 代码,只能休眠,等被唤醒后领取一个 P 再继续工作。
一段话背诵版
Go 调度器用 G、M、P 三个组件:G 是 goroutine,栈 2KB 起步、动态增长,执行完进两级空闲缓存复用;M 是 OS 线程,执行 Go 代码的最小单位;P 是调度上下文,数量等于 GOMAXPROCS,持有本地 G 队列。新 G 优先放当前 P 的 runnext,其次本地队列 runq(256 槽);本地满了就把队首一半连挤出的旧 G 一起搬进全局队列,一次搬一批是为了少碰全局锁。M 取 G 的顺序是 runnext → 本地队列 → 全局队列 → 偷其他 P 队首一半,每 61 次调度强制从全局取一次防饿死。普通阻塞只挂起 G,M 继续取下一个;系统调用阻塞走 hand off,把 P 交给别的 M。sysmon 负责 10ms 抢占(SIGURG)、syscall 超 20us 抢回 P、netpoll 兜底、2 分钟强制 GC。网络 IO 走 netpoll 不占 M,所以海量连接下活跃线程也就 GOMAXPROCS 上下。M0 是主线程永不退出,g0 是每个 M 专属的调度 goroutine。
G 的状态与生命周期
G 有明确的运行状态(runtime2.go 里的枚举):
- Gidle(空闲):刚分配,还没初始化。
- Grunnable(可运行):在运行队列里排队,等 M 取。
- Grunning(运行中):正在执行用户代码。
- Gsyscall(系统调用中):执行 syscall,暂时脱离调度。
- Gwaiting(等待中):被 park(挂起),等 channel、锁、IO。
- Gdead(已退出):执行完,进 free 列表等待复用。
flowchart LR
A["Gidle 空闲"] --> B["Grunnable 可运行"]
B --> C["Grunning 运行中"]
C --> D["Gsyscall 系统调用中"]
C --> E["Gwaiting 等待中"]
D --> B
E --> B
C --> F["Gdead 已退出"]
F --> B
flowchart LR
A["Gidle 空闲"] --> B["Grunnable 可运行"]
B --> C["Grunning 运行中"]
C --> D["Gsyscall 系统调用中"]
C --> E["Gwaiting 等待中"]
D --> B
E --> B
C --> F["Gdead 已退出"]
F --> B
flowchart LR
A["Gidle 空闲"] --> B["Grunnable 可运行"]
B --> C["Grunning 运行中"]
C --> D["Gsyscall 系统调用中"]
C --> E["Gwaiting 等待中"]
D --> B
E --> B
C --> F["Gdead 已退出"]
F --> B
flowchart LR
A["Gidle 空闲"] --> B["Grunnable 可运行"]
B --> C["Grunning 运行中"]
C --> D["Gsyscall 系统调用中"]
C --> E["Gwaiting 等待中"]
D --> B
E --> B
C --> F["Gdead 已退出"]
F --> BG 执行完后不直接销毁,而是进入空闲 G 缓存,等下次创建新 G 时复用。缓存分两级:每个 P 有一个本地缓存(pp.gFree),全局有一个共享缓存池(sched.gFree,加锁)。归还时先进 P 的本地缓存;创建新 G 时先从本地缓存取,本地缓存空了再去全局池一次搬 32 个补进来。一次搬一批是为了少碰全局锁:接下来 32 次创建都只从本地取。
复用的前提是栈能省则省。G 的栈会动态增长,执行完可能已经远大于起始大小。回收时栈大小不等于标准起始栈的,直接把栈释放;复用取出来发现栈不匹配,也释放重新分配。栈大小正好匹配的 G 才能连栈一起复用。
goroutine 栈起始只有 2KB(stackMin),远小于线程默认栈(MB 级),这是 G 轻量、能开几百万个的根本原因。栈不够用时动态增长,上限 1GB(64 位)。
队列结构
每个 P 有一个本地 G 队列,全局还有一个共享 G 队列。全局队列有锁保护(sched.lock,调度器锁),本地队列无锁,这是调度性能的关键。
flowchart TB
GQ["全局 G 队列
加锁保护"]
P1["P1"]
P2["P2"]
P3["P3"]
Q1["本地队列
下个执行位 + 运行队列[256]"]
Q2["本地队列"]
Q3["本地队列"]
M1["M1 内核线程"]
M2["M2 内核线程"]
M3["M3 内核线程(空闲)"]
CPU["CPU 核心
OS 调度"]
GQ -->|取 G| Q1
GQ -->|取 G| Q2
GQ -->|取 G| Q3
Q1 --- P1
Q2 --- P2
Q3 --- P3
P1 --- M1
P2 --- M2
P3 -. 空闲 .-> M3
M1 --> CPU
M2 --> CPU
flowchart TB
GQ["全局 G 队列
加锁保护"]
P1["P1"]
P2["P2"]
P3["P3"]
Q1["本地队列
下个执行位 + 运行队列[256]"]
Q2["本地队列"]
Q3["本地队列"]
M1["M1 内核线程"]
M2["M2 内核线程"]
M3["M3 内核线程(空闲)"]
CPU["CPU 核心
OS 调度"]
GQ -->|取 G| Q1
GQ -->|取 G| Q2
GQ -->|取 G| Q3
Q1 --- P1
Q2 --- P2
Q3 --- P3
P1 --- M1
P2 --- M2
P3 -. 空闲 .-> M3
M1 --> CPU
M2 --> CPU
flowchart TB
GQ["全局 G 队列
加锁保护"]
P1["P1"]
P2["P2"]
P3["P3"]
Q1["本地队列
下个执行位 + 运行队列[256]"]
Q2["本地队列"]
Q3["本地队列"]
M1["M1 内核线程"]
M2["M2 内核线程"]
M3["M3 内核线程(空闲)"]
CPU["CPU 核心
OS 调度"]
GQ -->|取 G| Q1
GQ -->|取 G| Q2
GQ -->|取 G| Q3
Q1 --- P1
Q2 --- P2
Q3 --- P3
P1 --- M1
P2 --- M2
P3 -. 空闲 .-> M3
M1 --> CPU
M2 --> CPU
flowchart TB
GQ["全局 G 队列<br/>加锁保护"]
P1["P1"]
P2["P2"]
P3["P3"]
Q1["本地队列<br/>下个执行位 + 运行队列[256]"]
Q2["本地队列"]
Q3["本地队列"]
M1["M1 内核线程"]
M2["M2 内核线程"]
M3["M3 内核线程(空闲)"]
CPU["CPU 核心<br/>OS 调度"]
GQ -->|取 G| Q1
GQ -->|取 G| Q2
GQ -->|取 G| Q3
Q1 --- P1
Q2 --- P2
Q3 --- P3
P1 --- M1
P2 --- M2
P3 -. 空闲 .-> M3
M1 --> CPU
M2 --> CPU新 G 创建时优先放进当前 P 的本地队列,最优先的位置是 runnext(下个优先执行位,每个 P 一个单槽,可能为空)。runnext 空就直接占住;被占着就把旧的挤出去,放进 runq(本地运行队列)环形数组。runq 是固定长度 256 的数组,加 runnext 共容纳 257 个 G。
runnext 里放的是这几类 G:新建的 G(go func())、被唤醒且需要尽快执行的 G(ready 传 next=true,比如 finalizer G)、被抢占让出的 G——让它 STW 结束后立刻续跑。
这是局部性原则:新 G 是当前 G 刚创建的,和它在同一 P 上运行,数据大概率还在 CPU 缓存里,立刻执行命中率最高。
本地队列放不下(runnext 被占且 runq 满 256)时,runqputslow(把一半 G 挪到全局队列的函数)把队首的一半(最早入队的 128 个 G)连同被挤出的旧 G(原本蹲在 runnext 里、还没轮到执行的 G)一起移到全局队列。移走旧的不移走新的:新 G 要马上执行,留在 runnext;队首的 G 等待最久,进全局队列后能被其他空闲 P 取走,顺带做了负载均衡。所以「P 的 G 队列长度最大值」是 256 + 1,不是无限。
新 G 占了 runnext,把被挤出的旧 G 丢进全局队列不就行了?为什么还要连队首一半(128 个 G)一起搬?
只把旧 G 丢进全局队列行不通:旧 G 进不了 runnext(已被新 G 占住),只能进 runq,而 runq 已经满 256。从 runq 腾出空间是硬需求,队首的 G 必然要动。
一次搬一批是为了少碰全局锁。全局队列有锁(sched.lock)。如果每次只腾 1 个位置:创建 G → runq 又满 → 又腾 1 个 → 又拿一次全局锁。goroutine 创建是热路径,每次创建都触发全局锁不可接受。一次搬 128 个,锁只拿一次,换来 runq 半空(128 个空位),接下来很多次入队都走无锁的本地路径,直到队列再次填满。搬一半也是留缓冲:runq 从 256 减到 128,新 G 不会立刻又顶满。
空闲 P 反正会 work stealing 来偷满队列,不搬不也行?
偷取本身有成本:多个空闲 P 同时盯上同一个满队列会互相抢,抢输的只能重试,白白消耗 CPU。把一半搬进全局后,空闲 P 直接走 globrunqgetbatch 一次锁拿一批,不用抢。但 work stealing 只是兜底手段——队列满说明生产速度大于消费速度,靠别人来偷解决不了本 P 入队被堵的问题,腾位置是必须的。
所以搬一半的核心原因是「没地方放 + 不能每次创建都加锁」,负载均衡只是顺带收益。
P 和 M 的数量
P 的数量等于 GOMAXPROCS(最大处理器数),默认是 CPU 逻辑核心数。可以用 GOMAXPROCS 环境变量或 runtime.GOMAXPROCS(n) 修改。
M 的数量动态变化:有工作需要处理但空闲 M 不够时新建;空闲 M 进入休眠列表(sched.midle,空闲 M 链表)等待复用,长期空闲会被销毁。上限默认 10000,可以用 runtime/debug.SetMaxThreads(设置最大线程数)修改。
每个 M 同一时刻绑定 0 个或 1 个 P:持有 P 的 M 才能执行 Go 代码。M 通过绑定的 P 获取 G,顺序是 runnext → 本地队列 → 全局队列 → 偷其他 P。
复用线程
work stealing(工作窃取)
P 的本地队列空了,取 G 的顺序是:先全局队列(一次取一批,globrunqgetbatch,数量 min(128, 全局长度, 全局长度/GOMAXPROCS+1) 个,全局长度是全局队列当前排队的 G 数;除以 GOMAXPROCS 是为了平均分给每个 P,128 是本地队列放不下的上限),再网络轮询(netpoll,把数据就绪的 G 捡回来),还空就偷其他 P。
偷的时候从目标 P 的 runq 取队首的一半(runqgrab 里 n = n - n/2,从 head 开始数 n 个),也就是最早入队的那一半。偷不到、全局也空,P 进入空闲列表等待。GOMAXPROCS 不变时 P 的数量固定,不会销毁;会休眠或销毁的是 M。
取 G 还有一层公平性保护:每 61 次调度(schedtick%61 == 0)且全局队列非空时,无视本地队列,直接从全局队列取 1 个 G 来执行。防止两个 G 在本地队列里互相 spawn 出新 G,把全局队列里的 G 活活饿死。
hand off(交接)
G 阻塞分两种情况,处理方式不同:
- 普通阻塞(channel 通道、锁、sleep 睡眠):G 被 park(挂起,暂停执行、等条件满足再唤醒),M 不换,继续从 P 的队列取下一个 G 执行。这时不需要新 M。
- 系统调用阻塞:M 带着 G 一起进入系统调用,P 被释放(
handoffp,交接函数),从休眠 M 里唤醒一个接管这个 P,没有就新建。
系统调用返回时,M 先尝试要回 P:有闲 P 就直接继续执行 G;没有闲 P,G 被放进全局队列(globrunqput),M 进入休眠(stopm),等下次被唤醒。
负载均衡
每次新建 G(newproc,创建新 G 的函数)都会调用 wakep(唤醒 M 的函数):如果当前没有自旋 M,且有空闲 P,就唤醒一个休眠 M 或新建 M,绑上这个 P,进入自旋模式去抢 G。
自旋 M 的取 G 顺序:本地队列(刚拿到的闲 P,队列本来就是空的)→ 全局队列 → 偷其他 P。
自旋 M 不会无限烧 CPU:
- 自旋 M 的数量有限制,最多是忙 P 数的一半(
2 × 自旋M数 < GOMAXPROCS - 空闲P数才允许新增)。 - 全局和所有 P 都找不到工作时,自旋 M 释放 P 回空闲列表,自己休眠(
stopm→mPark,线程挂起),等下次wakep唤醒。
sysmon 与抢占
sysmon(系统监控线程)是 runtime 启动时创建的独立线程(newm 创建,跑 sysmon 函数),负责几件事:
- 抢占:有工作时每 20us 检查一轮,空闲时最长 10ms。发现同一个调度时间片(schedtick)超过 10ms(
forcePreemptNS,抢占时间阈值)就触发:超时的可能是一个长时间运行的 G,也可能是一串经 runnext 连续执行的 G。通过 SIGURG 信号(一个用于抢占的 Unix 信号)异步抢占(Go 1.14+)。G 让出 CPU 回到队列继续排队,之后还会被调度到。 - syscall 接管:P 卡在系统调用里超过约 20us,sysmon 直接把 P 抢回来交给别的 M(配合 hand off)。
- 网络轮询兜底:超过 10ms 没人轮询网络事件,sysmon 自己轮一次 epoll,把就绪的 G 唤醒。
- 定时 GC:
forcegcperiod(2 分钟)没触发过 GC 就强制触发一次。
网络 IO 与 netpoll
网络 IO(socket 读写)在 Go 里默认走非阻塞模式,底层是 epoll(Linux)/ kqueue(macOS)这类 IO 多路复用。G 发起的读写没就绪时,netpollblock 里 gopark 挂起(waitReasonIOWait),不占 M。就绪事件由 netpoll 收集,把对应 G 唤醒塞回队列。
所以大量 goroutine 同时做网络 IO 时,M 不会被 IO 阻塞。这是 GMP 支撑高并发网络服务的核心:连接数再多,活跃线程也就 GOMAXPROCS 上下。
注意区分:文件 IO、syscall 阻塞走另一条路(hand off,占 M),网络 IO 走 netpoll,不占 M。
调度流程
go func(){...}():创建 G,优先放当前 P 的runnext,其次本地队列;本地队列满 256 时,队首一半 G 连同挤出的旧 G 一起移到全局队列。- M 从绑定的 P 取 G:
runnext→ 本地队列 → 全局队列 → 偷其他 P。 - G 阻塞:普通阻塞 M 继续取下一个 G;系统调用阻塞则交接 P 给其他 M。
- 系统调用返回:G 重新入队,M 抢 P 或休眠。
flowchart LR
A["go func()"] --> B["创建 G"]
B --> C{"P 本地队列满?"}
C -- 否 --> D["放下个执行位 / 本地队列"]
C -- 是 --> E["队首一半 G + 挤出的旧 G 移到全局队列"]
D --> F["M 从 P 取 G 执行"]
E --> F
F --> G{"G 阻塞?"}
G -- 普通阻塞 --> H["挂起 G,M 继续执行下一个 G"]
G -- 系统调用 --> I["交接 P 给其他 M"]
H --> F
I --> F
flowchart LR
A["go func()"] --> B["创建 G"]
B --> C{"P 本地队列满?"}
C -- 否 --> D["放下个执行位 / 本地队列"]
C -- 是 --> E["队首一半 G + 挤出的旧 G 移到全局队列"]
D --> F["M 从 P 取 G 执行"]
E --> F
F --> G{"G 阻塞?"}
G -- 普通阻塞 --> H["挂起 G,M 继续执行下一个 G"]
G -- 系统调用 --> I["交接 P 给其他 M"]
H --> F
I --> F
flowchart LR
A["go func()"] --> B["创建 G"]
B --> C{"P 本地队列满?"}
C -- 否 --> D["放下个执行位 / 本地队列"]
C -- 是 --> E["队首一半 G + 挤出的旧 G 移到全局队列"]
D --> F["M 从 P 取 G 执行"]
E --> F
F --> G{"G 阻塞?"}
G -- 普通阻塞 --> H["挂起 G,M 继续执行下一个 G"]
G -- 系统调用 --> I["交接 P 给其他 M"]
H --> F
I --> F
flowchart LR
A["go func()"] --> B["创建 G"]
B --> C{"P 本地队列满?"}
C -- 否 --> D["放下个执行位 / 本地队列"]
C -- 是 --> E["队首一半 G + 挤出的旧 G 移到全局队列"]
D --> F["M 从 P 取 G 执行"]
E --> F
F --> G{"G 阻塞?"}
G -- 普通阻塞 --> H["挂起 G,M 继续执行下一个 G"]
G -- 系统调用 --> I["交接 P 给其他 M"]
H --> F
I --> FP 的状态
P 有四种状态(runtime2.go):
- Pidle(空闲):在空闲 P 列表里,队列为空,等着被 M 领走。
- Prunning(运行中):被某个 M 持有,正在执行 G。
- Pgcstop(GC 停止):GC 的 STW(stop the world,暂停所有线程)阶段,所有 P 停在这里。
- Pdead(已销毁):GOMAXPROCS 调小时多余的 P 被销毁,调大时还能复用。
GC 开始时 STW:所有 M 跑到安全点,P 转 Pgcstop,调度暂停。标记阶段并发进行,P 恢复跑用户代码。GC 的完整原理见 Go GC 机制。
M0 和 G0
M0 是程序启动后的第一个 M(主线程),负责初始化(安装信号处理、创建 sysmon 线程等),且永不退出——mexit(线程退出流程)里对 m0 是 wedge(钉住,卡住不动),直到进程结束。
G0 是每个 M 的专属调度 goroutine。每次创建 M(allocm,创建 M 的函数)都会同时创建它的 g0,存在 M 的 g0 字段里。g0 不进任何队列、不占 P,只负责执行调度代码(schedule 调度、栈增长、信号处理等)。它独立于 P,只和 M 关联:M 阻塞时 g0 跟着 M 走,没有「g0 放哪里」的问题。
也不存在「m0 和 g0 解绑」:g0 永远属于 m0,m0 通过 g0 进入调度循环。
Hello world 执行流程
- 程序启动,创建 m0 和它的 g0。
- g0 执行
schedinit(调度器初始化):创建 GOMAXPROCS 个 P、初始化全局队列。 newproc创建 main goroutine,放入某个 P 的本地队列。- m0 的 g0 进入调度循环,取出 main goroutine,m0 转而执行它。
- main 执行完毕,程序退出。
runtime/trace
import "runtime/trace"
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// 业务代码生成后用 go tool trace trace.out 打开 Web UI(网页界面),可以看 G/P/M 的调度、阻塞、系统调用、GC 等事件。
面试题
G、M、P 分别负责什么?为什么不能只用 G 和 M?
- G 保存 goroutine 的执行状态,包括栈、程序计数器和调度信息。
- M 是操作系统线程,真正把指令交给 CPU 执行。
- P 保存调度上下文,管理本地运行队列和内存缓存。M 持有 P 才能执行 Go 代码。
如果只有 G 和 M,所有线程都要直接访问全局运行队列,创建和调度 G 会频繁竞争同一把锁。引入 P 后,大部分操作在本地队列完成;M 因系统调用阻塞时,P 还能交给其他 M,调度能力不会跟着线程一起卡住。
go func() 创建 goroutine 后会经过哪些调度步骤?
编译器把 go 语句转换为 newproc 调用。newproc1 先从当前 P 的空闲 G 缓存取一个 G,取不到才分配;初始化栈和入口地址后,将状态改为 Grunnable。
新 G 优先进入当前 P 的 runnext,其次进入 runq。M 进入调度循环后先取 runnext,再查本地队列、全局队列和网络轮询,最后尝试从其他 P 窃取工作。
work stealing(工作窃取)什么时候触发?一次会偷多少 G?
当前 P 找不到可运行 G 时,调度器会先检查全局队列和 netpoll(网络轮询)。这些位置都没有工作,才进入 work stealing(工作窃取)。
runqsteal 通过 runqgrab 从目标 P 的 runq 队首取约一半 G,并把其中一个立即交给当前 M。目标队列为空时,调度器也可能尝试偷 runnext,但会短暂退让,避免把即将运行的 G 在两个 P 之间来回搬动。
hand off(交接)在什么情况下发生?普通阻塞也会交接 P 吗?
channel、锁和 sleep 引起普通阻塞时,只挂起当前 G。M 仍持有 P,可以继续执行队列里的其他 G,不需要交接。
M 带着 G 进入阻塞系统调用时,P 会通过 handoffp 交给另一个 M。有空闲 M 就唤醒,没有就按需创建;系统调用返回后,原 M 尝试获取空闲 P,失败则把 G 放入全局队列并休眠。
sysmon(系统监控线程)负责什么?
sysmon 不绑定 P,独立执行 runtime 的监控工作。它会检查长时间运行的 G 并发出抢占请求,也会从长时间阻塞的系统调用中收回 P。
当普通调度路径长期没有轮询网络时,sysmon 会执行一次非阻塞 netpoll。它还负责触发长时间未发生的强制 GC,并根据定时器和调度状态调整检查节奏。
Go 1.14 之后的抢占式调度是怎样工作的?
sysmon 发现某个 P 的调度时间片超过 forcePreemptNS(10ms)后,会调用 preemptone。该函数设置 G 的抢占标记和栈保护值;平台支持时,再通过 preemptM 向对应 M 发出抢占信号。
信号处理流程会把执行转到 asyncPreempt,让 G 在异步安全点暂停并重新进入调度。它不是杀死 G,也不保证发出请求后立即完成;正在系统调用中的 G 由收回 P 的路径处理。
GOMAXPROCS 表示什么?调大后一定更快吗?
GOMAXPROCS 决定可同时执行 Go 代码的 P 数量,也限制同一时刻并行运行用户 Go 代码的 M 数量。它不限制 goroutine 总数,也不等于 runtime 能创建的线程总数。
调大后,CPU 密集任务有机会使用更多核心,但调度和 GC 并行开销也可能增加。超过可用 CPU 配额通常不会继续提速;频繁系统调用仍可能让 M 数量高于 GOMAXPROCS。
GMP 与“一个任务一个线程”的模型有什么区别?
一个任务一个线程时,任务调度主要交给操作系统。线程栈较大,线程切换需要进入内核;大量阻塞任务容易带来很多线程。
GMP 把大量 G 多路复用到较少的 M 上。P 用本地队列减少全局竞争,普通阻塞只挂起 G,网络 IO 通过 netpoll 唤醒 G;只有阻塞系统调用等情况需要让线程和 P 分离。