# 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 列表等待复用。

```mermaid
flowchart LR
    A["Gidle 空闲"] --> B["Grunnable 可运行"]
    B --> C["Grunning 运行中"]
    C --> D["Gsyscall 系统调用中"]
    C --> E["Gwaiting 等待中"]
    D --> B
    E --> B
    C --> F["Gdead 已退出"]
    F --> B
```

G 执行完后不直接销毁，而是进入空闲 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`，调度器锁），本地队列无锁，这是调度性能的关键。

```mermaid
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。

## 调度流程

1. `go func(){...}()`：创建 G，优先放当前 P 的 `runnext`，其次本地队列；本地队列满 256 时，队首一半 G 连同挤出的旧 G 一起移到全局队列。
2. M 从绑定的 P 取 G：`runnext` → 本地队列 → 全局队列 → 偷其他 P。
3. G 阻塞：普通阻塞 M 继续取下一个 G；系统调用阻塞则交接 P 给其他 M。
4. 系统调用返回：G 重新入队，M 抢 P 或休眠。

```mermaid
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
```

## P 的状态

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 机制](/posts/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 执行流程

1. 程序启动，创建 m0 和它的 g0。
2. g0 执行 `schedinit`（调度器初始化）：创建 GOMAXPROCS 个 P、初始化全局队列。
3. `newproc` 创建 main goroutine，放入某个 P 的本地队列。
4. m0 的 g0 进入调度循环，取出 main goroutine，m0 转而执行它。
5. main 执行完毕，程序退出。

## runtime/trace

```go
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 分离。


---

> 作者: Nite  
> URL: https://www.nite07.com/zh-cn/posts/go-gmp/  

