Go GMP 原理与调度

Go 语言的高并发能力源于其精巧的运行时调度器。理解 GMP 模型是掌握 Go 并发的关键——它解释了为什么你可以轻松创建上万个 goroutine 而不用担心性能问题。本文从调度器的演进历史讲起,逐步拆解 G、M、P 三个核心角色及其协作机制,并通过 11 个实战场景带你直观理解调度全过程。

1. 调度器演进:从单进程到 goroutine

1.1 单进程时代:不需要调度器

早期操作系统只支持单进程——一个程序跑完,下一个才能开始。所有任务串行执行。

两个致命问题

  • 单一执行流:计算机一次只能处理一个任务
  • 进程阻塞浪费 CPU:等待 I/O 时 CPU 完全闲置
进程 A 运行完毕 ──→ 进程 B 才开始 等待 I/O 期间,CPU 完全空闲 ⚠️

1.2 多进程 / 多线程时代

操作系统引入多进程并发:进程 A 阻塞时,CPU 立即切换到进程 B。宏观上"同时"运行多个程序。

新问题来了

  • 高内存占用:每个进程虚拟内存 4GB(32 位),每个线程约 4MB
  • 调度开销大:进程/线程的创建、切换、销毁成本高,CPU 大量时间花在调度上
  • 并发编程复杂:锁、竞争、同步等问题让多线程开发很头疼

1.3 协程(Coroutine)登场

工程师发现:线程分为"内核态线程"和"用户态线程"两层。CPU 只知道内核线程,用户态线程可以更轻量。

三种协程与线程的映射关系

模型描述优点缺点
N:1N 个协程绑 1 个线程用户态切换极快无法利用多核,一阻塞全阻塞
1:11 个协程绑 1 个线程实现简单,利用多核创建/切换成本由内核完成,昂贵
M:NM 个协程绑 N 个线程兼顾轻量与多核实现最复杂

Go 的 goroutine 就是 M:N 模型的实现——既轻量又能利用多核。

1.4 goroutine 的特点

  • 内存极小:一个 goroutine 初始栈仅 2KB,且可动态伸缩
  • runtime 调度:由 Go 运行时调度,而非操作系统
  • 协作式 + 抢占式:正常情况下协程主动让出 CPU,但 Go 也加入了基于信号的抢占机制(10ms 强制切换)

1.5 被废弃的老调度器

Go 在 2012 年重新设计调度器前,使用的是一个简单的 G-M 模型

  • 全局只有一个 G 队列,所有 M(线程)都从那里取 G(goroutine)
  • 所有操作都要加互斥锁保护全局队列

老调度器的三大缺陷

  1. 激烈锁竞争:创建/销毁/调度 G 都需抢锁
  2. 局部性差:G 创建新 G 后可能被丢到其他 M 执行,相关 goroutine 被分散
  3. 系统调用开销大:线程频繁阻塞/唤醒增加系统负担
老模型(全局锁): M1 ──┐ M2 ──┼── 🔒 Global Queue(所有 G)── CPU M3 ──┘ ↑ 问题:所有 M 竞争同一把锁

2. GMP 模型核心组件

为解决老调度器的问题,Go 引入了 P(Processor),形成 GMP 三元素模型。

2.1 G(Goroutine)

G 是 Go 中的协程,代表一个待执行的任务。每个 G 包含:

  • :goroutine 的执行栈(初始 2KB,可增长到 1GB)
  • 指令指针:当前执行位置
  • 状态:_Gidle、_Grunnable、_Grunning、_Gsyscall、_Gwaiting、_Gdead 等
  • 绑定的 M(如果正在运行)
gotype g struct {
    stack       stack     // 执行栈
    sched       gobuf     // 调度上下文(sp, pc, g 等)
    m           *m        // 当前绑定的 M
    // ...
}

2.2 M(Machine / OS Thread)

M 是操作系统线程,是真正执行代码的实体。每个 M 必须绑定一个 P 才能执行 G。

关键特性

  • M 数量默认上限 10000,但受限于操作系统
  • M 阻塞时,P 会被转移到其他空闲 M
  • 一个 Go 程序至少有一个 M(M0)

2.3 P(Processor)

P 是 GMP 模型的调度核心,代表执行 goroutine 所需的上下文资源。

P 的关键属性: ┌────────────────────────────────────┐ │ P │ │ ├── runq [256]G ← 本地 G 队列 │ │ ├── runnext *G ← 下一个优先执行的 G │ │ ├── m *M ← 当前绑定的 M │ │ ├── status ← 状态 │ │ └── ... ← 缓存、GC 等 │ └────────────────────────────────────┘

P 的数量:由 GOMAXPROCS 决定(默认等于 CPU 核心数),程序运行期间不变。

2.4 全局队列(Global Queue)

除了每个 P 的本地队列,还有一个全局 G 队列。它在新调度器中作用被弱化:

  • P 本地队列满时,溢出的 G 放入全局队列
  • M 从其他 P 偷不到 G 时,从全局队列获取
  • 全局队列仍有互斥锁,但访问频率远低于老模型

2.5 三者的协作关系

┌──────────────────────────────┐ │ 全局 G 队列 │ │ [G7] [G8] [G9] ... │ └──────┬───────────────────────┘ │ (本地满时放入 / 偷不到时获取) ┌───────────┼───────────┬───────────┐ ▼ ▼ ▼ ▼ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ P0 │ │ P1 │ │ P2 │ │ P3 │ │ G队列 │ │ G队列 │ │ G队列 │ │ G队列 │ │ [G1] │ │ [G3] │ │ [G5] │ │ │ │ [G2] │ │ [G4] │ │ │ │ │ └──┬────┘ └──┬────┘ └──┬────┘ └──┬────┘ │ 1:1 │ 1:1 │ │ ▼ ▼ ▼ ▼ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ M0 │ │ M1 │ │ M2 │ │ M3 │ │ OS │ │ OS │ │ OS │ │ 自旋 │ │Thread │ │Thread │ │Thread │ │(spin) │ └───┬───┘ └───┬───┘ └───┬───┘ └───────┘ │ │ │ ▼ ▼ ▼ CPU 0 CPU 1 CPU 2 CPU 3

核心规则

  • P 的数量决定了真正并行的 goroutine 数量上限
  • M 必须"持有"一个 P 才能运行 G
  • P 与 M 是 1:1 绑定关系(任意时刻)
  • M 阻塞时,P 可以被转移到另一个 M

3. 调度器四大设计策略

3.1 线程复用(Work Stealing)

当某个 M 的 P 本地队列为空时,不会销毁线程,而是:

  1. 先从全局队列取一批 G
  2. 全局队列也空,就"偷"其他 P 本地队列一半的 G
偷取前: 偷取后: P0: [G1, G2, G3] P0: [G1] ← 留一半 P1: [ (空的) ] P1: [G2, G3] ← 偷到一半

3.2 Hand Off 机制

当 M 正在执行的 G 发生阻塞系统调用(如文件 I/O、网络 I/O)时:

1. M1 执行 G 时发生 syscall → M1 阻塞 2. P 与 M1 解绑(detach) 3. P 寻找一个空闲 M(或新建 M)并绑定 4. P 继续执行本地队列中剩余的 G 5. syscall 返回后,G 尝试回到某个 P 继续执行

3.3 抢占调度

传统协程是协作式的(主动让出),一个协程不主动让出就会饿死其他协程。Go 在 1.14 引入基于信号的异步抢占

  • 每个 goroutine 最多连续占用 CPU 10ms
  • sysmon 监控线程检测到运行超时的 G,发送 SIGURG 信号
  • 信号处理中完成抢占

3.4 利用并行

GOMAXPROCS 控制 P 的数量,也就控制了同时运行 goroutine 的 OS 线程数。默认等于 CPU 核数,让程序充分利用多核。

4. go func() 调度全流程

当你写下 go func() { ... }() 时,调度器经历了什么?

go func() 创建新 goroutine G'
G' 优先放入当前 P 的本地队列(局部性原则)
本地队列未满 → 直接放入 runq
本地队列已满(256个) → 将一半 G + G' 转移到全局队列

M 的调度循环(简化)

goschedule() {
    for {
        // 1. 每 61 次调度检查一次全局队列
        // 2. 从 P 本地队列取 G
        // 3. 本地空 → 从全局队列取一批
        // 4. 全局空 → 从其他 P 偷一半(work stealing)
        // 5. execute(G) → 运行 G
    }
}

阻塞时的处理

阻塞类型处理方式
阻塞 syscall(文件 I/O)P 与 M 解绑,P 换新 M 继续运行其他 G
非阻塞 syscall(网络 I/O)G 进入 netpoller,M 继续执行其他 G
channel 操作阻塞G 挂到 channel 等待队列,M 执行其他 G
time.SleepG 放入定时器堆,M 继续执行其他 G

5. 调度器生命周期

5.1 特殊的 M0 和 G0

角色说明
M0程序启动时的主线程,存在全局变量 runtime.m0 中。负责初始化,之后与普通 M 无异
G0每个 M 都有一个 G0,仅用于调度逻辑,不执行用户代码。调度或系统调用时使用 G0 的栈

5.2 Hello World 调度全过程

以最简单的 fmt.Println("Hello world") 为例:

runtime 创建 m0 和 g0,将二者关联
调度器初始化:初始化 m0、栈、GC,创建 GOMAXPROCS 个 P
创建 main goroutine(执行 runtime.main),加入 P0 本地队列
启动 m0,绑定 P0,从 P0 获取 main goroutine
m0 根据 G 的栈和上下文设置运行环境 → 执行 main.main → 打印
main.main 退出 → m0 回到调度循环 → runtime.main 执行清理 → 退出

6. 十一个调度场景实战解析

说明:以下场景假定每个 P 本地队列容量暂按 3 个 G 计算(实际为 256),以便直观演示。

场景 1:创建新 goroutine

P1 上 M1 正在运行 G1。G1 中执行 go func() 创建 G2。G2 优先加入 P1 的本地队列

P1 本地队列: [G2] ← 新创建的 M1 正在运行: G1

场景 2:goroutine 执行完毕,线程复用

G1 执行完毕(调用 goexit),M1 上切换到 G0(调度 goroutine)。G0 从 P1 本地队列取出 G2,切换到 G2 继续执行。M1 被复用,没有销毁重建

调度前后(同一线程 M1): G1 (完成) → G0 (调度) → G2 (执行)

场景 3-4:本地队列满,负载均衡

G2 一口气创建了 6 个 goroutine(G3~G8)。前 3 个(G3、G4、G5)填满 P1 本地队列。创建 G6 时发现队列已满,将前一半(G3、G4)和新创建的 G6 一起移到全局队列(顺序被打乱)。

移入全局队列前: 移入全局队列后: P1: [G3, G4, G5, G6→] P1: [G5, G7, G8] 全局: [G3, G4, G6](乱序)

场景 5:有空位继续放入

G2 创建 G8 时,P1 有空位,G8 直接加入 P1 本地队列。

场景 6:唤醒空闲 M

G2 运行时可能唤醒一个空闲的 P2-M2 组合。M2 绑定 P2 后进入自旋状态(不断寻找 G 来执行)。

场景 7:从全局队列取 G

自旋的 M2 发现 P2 本地队列为空,从全局队列取 G。取的数量公式为 n = min(len(GQ)/GOMAXPROCS + 1, len(GQ)/2),至少 1 个,且不会一次取光。

全局队列: [G3, G4, G6] n = min(3/2 + 1, 3/2) = min(2, 1) = 1 P2 取到 G3,开始运行 G3

场景 8:Work Stealing

P2 执行完本地所有 G,全局队列也空了。M2 执行 work stealing——从 P1 的本地队列尾部"偷"一半 G。

偷取前: 偷取后: P1: [G5, G7, G8] P1: [G5, G7] ← 还剩 2 个 P2: [ (空) ] P2: [G8] ← 偷到 G8

场景 9:自旋线程

M3 和 M4 没有 G 可执行,进入自旋状态。自旋线程不执行 G 但占用 CPU——为什么?

自旋是为了"随时待命":当新 goroutine 创建时,立刻有 M 能执行它。如果销毁再新建会增加时延。系统最多允许 GOMAXPROCS 个自旋线程,多余的会休眠。

场景 10:Hand Off(阻塞系统调用)

M2 上的 G8 发生阻塞 syscall(如读文件),且 G8 创建了 G9:

G8 阻塞 → M2 被操作系统挂起
P2 与 M2 解绑(detach)
P2 判断:本地队列有 G9 → 唤醒空闲 M5 并绑定
M5 绑定 P2,继续执行 G9
syscall 返回 → G8 尝试获取空闲 P → 有则加入,无则 M2 休眠、G8 入全局队列

场景 11:非阻塞网络 I/O

G8 发起网络 I/O(非阻塞),G8 被放入 netpoller 中等待。G8 创建的 G9 留在 P2 本地队列继续执行。M2 不会阻塞,P2 也不需要换 M。

阻塞 vs 非阻塞的关键区别:阻塞系统调用需要 hand off(P 换 M),非阻塞网络 I/O 通过 netpoller 异步处理,M 和 P 都不受影响。

7. 可视化 GMP

7.1 GODEBUG 环境变量

最简单的 GMP 观察方式——运行时打印调度器状态:

gopackage main

import (
    "fmt"
    "time"
)

func main() {
    for i := 0; i < 5; i++ {
        time.Sleep(time.Second)
        fmt.Println("Hello World")
    }
}
bash$ go build -o trace2 trace2.go
$ GODEBUG=schedtrace=1000 ./trace2

输出解读:

字段含义
SCHED调度器输出标志
0ms程序启动后的时间
gomaxprocs=2P 的数量
idleprocs=2空闲 P 的数量
threads=4OS 线程(M)总数
spinningthreads=0自旋线程数
idlethreads=2空闲线程数
runqueue=0全局队列中 G 的数量
[0 0]各 P 本地队列中 G 的数量

7.2 go tool trace

更强大的可视化工具,能在浏览器中查看 G、M、P 的调度关系图:

gopackage main

import (
    "os"
    "fmt"
    "runtime/trace"
)

func main() {
    f, _ := os.Create("trace.out")
    defer f.Close()

    trace.Start(f)
    defer trace.Stop()

    fmt.Println("Hello World")
}
bash$ go run trace.go
$ go tool trace trace.out
# 浏览器打开 http://127.0.0.1:xxxxx → 点击 "view trace"

trace 页面按时间轴展示每个 G、M、P 的状态变化,可以直观看到 goroutine 的创建、切换、阻塞和唤醒全过程。

8. 总结

GMP 模型的精髓可以用一句话概括:

G 是需要执行的任务,M 是执行者(OS 线程),P 是执行所需的资源上下文。P 是 G 和 M 之间的"调度中介",通过本地队列减少锁竞争,通过 work stealing 和 hand off 实现高效的线程复用。

核心要点速查

概念关键点
Ggoroutine,轻量级协程,初始栈 2KB
MOS 线程,必须绑定 P 才能执行 G
P调度核心,含本地 G 队列(256 上限),数量 = GOMAXPROCS
全局队列P 本地满时溢出,偷不到时兜底
Work StealingP 本地空 → 从其他 P 偷一半 G
Hand OffM 阻塞 → P 换另一个 M 继续
抢占goroutine 最多连续 10ms,超时强制切换
M0程序启动的第一个线程
G0每个 M 的调度专用 goroutine

理解 GMP,你对 Go 并发的理解就从"会用 goroutine"上升到"理解 goroutine 怎么跑"——这是写出高性能 Go 程序的基础。

目录