Go GMP 模型
GMP 是 Go 调度器的三个核心组件:
- G(Goroutine,协程):
go func()创建的任务。 - M(Machine,机器):操作系统线程。
- P(Processor,处理器):调度上下文,管理 G 队列。
M 就是内核级线程,由 runtime 通过 newm/newosproc(创建 M、创建线程的函数)调用系统接口创建,再交给 OS 调度到 CPU 上执行。M 是执行 Go 代码的最小单位,没有 P 的 M 只能休眠或处理系统调用。
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 单独放全局?
疑问:新 G 占了 runnext,把被挤出的旧 G 丢进全局队列不就行了?为什么还要连队首一半(128 个 G)一起搬?
答案分两层:
旧 G 必须有个去处,runq 必须腾位置。 被挤出的旧 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 进入空闲列表等待。P 的数量固定,不会销毁;会休眠或销毁的是 M。
取 G 还有一层公平性保护:每 61 次调度(schedtick%61 == 0)强制检查一次全局队列,有 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 函数),负责几件事:
- 抢占:最多每 10ms 检查一次,发现某个 G 在同一 P 上连续运行超过 10ms(
forcePreemptNS,抢占时间阈值),就通过 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 等事件。