Go GMP 调度模型
Go 为什么能轻松起几万个 goroutine?核心在于 GMP 调度模型。
三个角色
| 组件 | 全称 | 含义 |
|---|---|---|
| G | Goroutine | 用户态轻量级协程,携带着要执行的函数和栈 |
| M | Machine | 操作系统线程,真正执行代码的实体 |
| P | Processor | 逻辑处理器,持有本地运行队列和 G 的上下文 |
┌──────────────────────────────────────┐
│ Go Runtime │
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ P │ │ P │ │ P │ ... │
│ │ ┌─┐ │ │ ┌─┐ │ │ ┌─┐ │ │
│ │ │M│ │ │ │M│ │ │ │M│ │ │
│ │ └─┘ │ │ └─┘ │ │ └─┘ │ │
│ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │
│ ┌──┴────────┴────────┴──┐ │
│ │ Global Run Queue │ │
│ └────────────────────────┘ │
└──────────────────────────────────────┘
数量关系
| 组件 | 数量 | 说明 |
|---|---|---|
| P | GOMAXPROCS(默认 CPU 核数) | 同时并行执行的 G 上限 |
| M | 上限 10000,实际动态增减 | 阻塞时 runtime 会新建 M |
| G | 无限制(受内存制约) | 初始栈只有 2KB |
import "runtime"
fmt.Println(runtime.GOMAXPROCS(0)) // 当前 P 数量
fmt.Println(runtime.NumGoroutine()) // 当前 G 数量
调度流程
- 每个 P 有一个本地 G 队列(256 容量)
- M 绑定一个 P,从 P 的本地队列取 G 执行
- 本地队列空了,从 Global Run Queue 取
- 全局也空了,从其他 P 的本地队列偷一半(work stealing)
┌─────────────────────────────────────┐
│ P0 本地队列 │
│ ┌──┬──┬──┬──┬──┐ │
│ │G1│G2│G3│G4│G5│ ← M0 正在运行 G1 │
│ └──┴──┴──┴──┴──┘ │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ P1 本地队列 │
│ ┌──┬──┐ │
│ │G6│G7│ ← M1 正在运行 G6 │
│ └──┴──┘ │
│ ┌──────────────────────┐ │
│ │ Global Run Queue │ ← 被偷之前│
│ │ (空的,P0 来偷) │ 空,P0 │
│ └──────────────────────┘ 过来偷 │
└─────────────────────────────────────┘
关键调度点
G 会在以下时机让出执行权:
- 主动调用
runtime.Gosched() - channel 操作阻塞 —
ch <- v或v := <-ch - 系统调用 — 文件 IO、网络 IO 等(此时 M 被阻塞,P 会绑定新 M)
- 抢占调度 — Go 1.14 起基于信号的抢占,防止死循环占用 CPU
- 函数调用 — 检查栈是否要扩容(
morestack)
系统调用 vs 网络 IO
同步系统调用
G1 → M1 → P1 执行 syscall (read 文件)
syscall 阻塞 → M1 和 P1 解绑
runtime 找/新建 M2 绑定 P1 继续执行其他 G
syscall 返回 → G1 回到 P1 队列,M1 休眠
网络 IO(异步)
G1 执行 net.Dial → runtime 把 fd 注册到 netpoller
G1 挂起,M 去执行其他 G
netpoller 检测到 fd 就绪 → G1 重新变为 runnable
这就是 Go 网络 IO 不占用线程的原因。
总结
- GMP 是 Go 高并发的基石
- M:N 调度:M 个 goroutine 映射到 N 个 OS 线程
- P 的数量决定并行度,默认等于 CPU 核数
- work stealing 保证负载均衡
- 网络 IO 走 netpoller,不阻塞 M