Go GMP 调度模型详解
一、核心概念
GMP 是 Go runtime 的三大核心组件:
| 组件 | 全称 | 本质 |
|---|---|---|
| G | Goroutine | 协程,包含栈、指令指针、调度信息等,初始栈 2KB |
| M | Machine | OS 线程,实际执行者,负责从 P 获取 G 并执行 |
| P | Processor | 逻辑处理器,持有本地 runqueue(LRQ),默认数量 = CPU 核数 |
核心关系:
M1 ──┐ M2 ──┐
P1 P2
┌─────┐ ┌─────┐
│ LRQ │ │ LRQ │ ← 本地队列(无锁环形队列,最多256个G)
├─────┤ ├─────┤
│ G │ │ G │
│ G │ │ G │
│ ... │ │ ... │
└─────┘ └─────┘
\ /
\ /
┌──────────────┐
│ GRQ(全局队列)│ ← 所有P共享,LRQ溢出时存入
└──────────────┘
二、调度原理
1. 调度循环(核心)
每个 M 在绑定的 P 上不断执行:
for {
// 1. 检查定时器
// 2. 从 LRQ 获取 G(work-stealing 优先)
// 3. LRQ 空 → 从 GRQ 获取(batch取,最多一半)
// 4. GRQ 也空 → 从其他 P 的 LRQ 偷取(work-stealing)
// 5. 都没有 → 执行 netpoll(检查网络IO就绪的G)
// 6. 还没有 → 释放 P,进入休眠
}
2. 四种调度场景
① 创建新 G
- 当前 P 的 LRQ 未满 → 放入 LRQ(本地队列,无锁)
- LRQ 已满(256个)→ 将前一半移到 GRQ,新 G 放入 LRQ
② G 阻塞(syscall/网络IO)
- 系统调用阻塞:M 解绑 P,P 转交给其他空闲 M;如果没有空闲 M 且有其他 G 等待,创建新 M
- 网络IO:G 不阻塞 M,而是注册到 netpoller,M 继续执行其他 G;IO 就绪后 netpoll 唤醒 G 重新入队
③ G 执行完毕
- 递归调用
schedule(),获取下一个 G
④ 抢占式调度(Go 1.14+)
- 基于信号的协作式抢占改为基于信号的异步抢占
- runtime 在栈帧中插入
morestack检查点 - 运行时间超过
GOMAXPROCS*10ms的 G 会被标记为可抢占 - 发送 SIGURG 信号,在 signal handler 中完成栈分裂和抢占
3. Work-Stealing 机制
当 P 的 LRQ 为空时:
P1 (空) ──偷取──→ P2 (有32个G)
│
└─ 每次偷取一半,保证负载均衡
- 从随机一个 P 开始尝试,避免集中竞争
- 偷取数量 = 目标 LRQ 长度 / 2,最少偷 1 个
- 这是 GMP 高效的关键:局部性优先(LRQ无锁),全局均衡(stealing)
4. Hand Off 机制
G1 发起阻塞 syscall
↓
M1 解绑 P1,P1 进入 idle list
↓
如果存在 idle M → M_new 绑定 P1 继续调度
如果不存在 idle M → 创建新 M
↓
M1 的 syscall 返回
↓
M1 尝试获取空闲 P → 成功则恢复执行
→ 失败则 G1 放入全局队列,M1 休眠
三、关键参数
| 参数 | 默认值 | 说明 |
|---|---|---|
GOMAXPROCS |
CPU 核数 | P 的数量,决定并行度 |
GODEBUG=schedtrace=1000 |
- | 每1000ms打印调度信息 |
runtime.GOMAXPROCS(n) |
- | 动态调整P数量 |
核心结论:P 数量决定了能同时执行的 G 数量上限。 GOMAXPROCS=1 时所有 G 串行执行。
四、常见用法
1. 控制并行度
go
// CPU 密集型:GOMAXPROCS 保持默认(= 核数)最佳
// IO 密集型:可以适当增大,但意义不大,因为IO不占M
runtime.GOMAXPROCS(runtime.NumCPU())
2. 协程池模式(控制并发)
go
// 用 buffered channel 做信号量
sem := make(chan struct{}, 100) // 最多100个并发goroutine
for i := 0; i < 10000; i++ {
sem <- struct{}{}
go func(id int) {
defer func() { <-sem }()
doWork(id)
}(i)
}
3. 观察调度状态
go
// 开启调度追踪
_ = os.Setenv("GODEBUG", "schedtrace=1000,scheddetail=1")
输出示例:
SCHED 1000ms: gomaxprocs=8 idleprocs=0 threads=20 spinningthreads=0 idlethreads=12 runqueue=0 [0 0 0 0 0 0 0 0]
4. 用 runtime 包获取调度信息
go
fmt.Println("Goroutines:", runtime.NumGoroutine())
fmt.Println("Threads:", runtime.NumThread()) // 不是公开API,但可通过 /debug/pprof/ 查看
五、常见问题与排查
1. Goroutine 泄漏
症状: runtime.NumGoroutine() 持续增长不回落
常见原因:
go
// ❌ channel 无消费者,goroutine 永久阻塞在发送
ch := make(chan int)
go func() { ch <- 1 }()
// ❌ channel 无生产者,goroutine 永久阻塞在接收
ch := make(chan int)
go func() { <-ch }()
// ❌ select 中所有 case 都阻塞,没有 default
go func() {
select {
case <-ch1:
case <-ch2:
}
}()
排查工具:
bash
go tool pprof http://localhost:6060/debug/pprof/goroutine
2. 线程数暴增
症状: top 看到 Go 进程线程数远超预期
原因: 大量 goroutine 同时做阻塞式系统调用(cgo、文件IO),每个都会 Hand Off 导致创建新 M
解决方案:
- 避免高并发 cgo 调用
- 文件 IO 考虑用线程池限制
debug.SetMaxThreads(max)设置上限
3. 调度延迟 / 抢占不及时(Go < 1.14)
症状: 某个 G 长时间占用 M,其他 G 饿死
go
// Go 1.13 及以下的问题:for 循环中没有函数调用,不会触发栈检查
for {
// 紧密循环,不会被抢占(Go < 1.14)
}
// 修复:显式调用 runtime.Gosched() 让出时间片
for {
runtime.Gosched()
}
Go 1.14+ 已解决: 基于信号的异步抢占,无需手动 Gosched()。
4. STW(Stop The World)过长
原因:
- GC 触发时需要暂停所有 M
- 大量 goroutine 导致栈扫描时间长
排查:
go
import _ "runtime/pprof"
// 访问 /debug/gctrace
5. Sysmon 后台监控
runtime 有一个独立的 M 运行 sysmon(不绑定 P),负责:
- 抢占长时间运行的 G(
retake) - 回收空闲的 P 和 M
- 触发 GC(超过 2 分钟没有 GC)
- 处理定时器
六、GMP vs 其他模型对比
| 特性 | GMP | 协作式调度 | 多线程模型 |
|---|---|---|---|
| 创建成本 | 极低(2KB) | 低 | 高(MB级栈) |
| 切换成本 | 几十ns | 几十ns | 几μs |
| 调度复杂度 | 高(三层抽象) | 低 | 低 |
| 系统调用处理 | Hand Off | 阻塞线程 | 阻塞线程 |
| 可扩展性 | 百万级 | 万级 | 千级 |
七、一句话总结
GMP 的本质是用 P 做中间层解耦了 G 和 M:G 无感知 M 的阻塞/创建/销毁,P 做本地缓存实现无锁调度,Work-Stealing 保证负载均衡,Hand Off 保证 syscall 不饿死其他 G。