Go 专家(07):GMP 调度深入 —— 抢占调度、工作窃取、sysmon
更新时间:2026-09-01。本文是
languages/go/expert/专家层第 07 篇,接 channel 底层。Go 调度器从 Go 1.0 的 G-M 模型进化到 1.1+ 的 GMP 模型,再到 Go 1.14 的信号抢占调度。理解调度器的细节,才能写出高性能的 Go 代码。
本文要回答的问题
- 协作式抢占和信号抢占有什么区别?Go 什么时候用哪种?
- 工作窃取(work stealing)怎么实现?为什么不用全局队列?
- sysmon 线程做什么?为什么需要监控线程?
- M 阻塞了(系统调用),P 怎么办?G 怎么办?
一、GMP 调度模型回顾
- G:goroutine,包含栈、上下文、状态
- M:machine,OS 内核线程,实际执行者
- P:processor,逻辑处理器,持有本地运行队列 runq
GOMAXPROCS 决定 P 的数量。每个 M 必须绑定一个 P 才能执行 G。
M1 → P1 → G1, G2, G3 (runq)
M2 → P2 → G4, G5, G6 (runq)二、调度时机:什么时候触发调度
一个 G 从运行到被切换,发生在以下时机:
- 阻塞:channel 发送/接收、Mutex 锁、系统调用、time.Sleep
- 主动让出:
runtime.Gosched() - 抢占:Go 1.14 之前的协作式抢占,Go 1.14+ 的信号抢占
- 创建新 G:go 语句创建新 goroutine,可能触发调度
- GC STW:GC 开始和结束,所有 G 暂停
三、协作式抢占 vs 信号抢占
Go 1.13 及之前:协作式抢占
- 编译器在每个函数的序言(prologue)里插入抢占检查
- 检查
stackguard0标志,如果被设置,就进入调度 - 问题:如果某个函数没有"函数序言"(比如纯数学计算的大循环),就不会触发抢占
- 一个 goroutine 如果一直循环,不调用任何函数,其他 goroutine 没法运行
Go 1.14+:信号抢占
- 使用
SIGURG信号(在 Linux 上)向目标 M 发送信号 - 信号处理函数里设置抢占标志,让当前 G 进入调度
- 解决了大循环不调度的问题
go
// Go 1.14 以前,这个循环会卡死其他 goroutine:
func loop() {
for i := 0; ; i++ {
// 没有函数调用,没有分配
}
}
// Go 1.14+,信号抢占会打断这个循环四、工作窃取(Work Stealing)
当 P 的本地队列 runq 为空时,P 会从其他地方窃取 G:
- 从全局队列(global runq)取 G
- 从其他 P 的本地队列偷一半 G(会随机选一个 P 偷)
- 如果都偷不到,M 进入自旋状态(spinning)
go
// 伪代码
func findRunnable() *g {
// 1. 从本地 runq 取
if g := getFromLocalRunq(); g != nil {
return g
}
// 2. 从全局队列取
if g := getFromGlobalRunq(); g != nil {
return g
}
// 3. 从其他 P 偷
if g := stealFromOtherP(); g != nil {
return g
}
// 4. 都偷不到,P 空闲
return nil
}为什么不用全局队列?
- 全局队列需要加锁,竞争激烈
- 每个 P 有本地队列,大部分操作无锁
- 只有本地队列空了,才去全局队列取
五、sysmon:监控线程
sysmon 是一个特殊的 M,不需要绑定 P,负责监控整个调度器:
- 抢占监控:G 运行超过 10ms 就发送信号
- 网络轮询:netpoll 非阻塞 I/O 检查
- syscall 监控:长时间阻塞的 syscall,把 P 拿出来给其他 M
- GC 触发:检查是否需要触发 GC
sysmon 每 20μs 到 10ms 检查一次,根据系统负载动态调整。
六、M 阻塞(系统调用)时
当 M 进入系统调用(比如 os.Open),超过一定时间:
- M 释放 P(P 被拿回调度器)
- P 分配给另一个 M 继续执行
- 系统调用返回后,M 尝试拿回 P
- 如果拿不到 P,M 阻塞在 syscall 上,等有 P 空闲
M1 进入 syscall → P1 释放 → P1 分配给 M2 执行其他 G
M1 syscall 返回 → 尝试拿回 P1(可能被 M2 占着)→ 拿不到就等七、GOMAXPROCS 的调优
go
runtime.GOMAXPROCS(0) // 默认 CPU 核心数- CPU 密集型:GOMAXPROCS = CPU 核心数
- I/O 密集型:GOMAXPROCS 可以略大于 CPU 核心数(因为很多 G 在等 I/O)
- 容器环境:注意 CFS 限制,检查 cgroup 的 CPU 配额
八、常见坑对照
| 坑 | 现象 | 对策 |
|---|---|---|
| 容器里 GOMAXPROCS 默认识别的还是宿主机的 CPU 核心数 | 被限制但 GOMAXPROCS 值太大 | 用 uber-go/automaxprocs 自动适配 |
| 纯计算循环不调用函数,Go 1.14 以前不调度 | 其他 goroutine 饿死 | 升级到 Go 1.14+ 或手动调用 runtime.Gosched() |
| 系统调用过长,P 被释放 | 系统调用返回后 M 可能拿不到 P | 理解 sysmon 机制,减少长 syscall |
相关与延伸
下一篇:Go 内存分配 —— tcache、mcache、mheap;GMP 基础,见 goroutine 与 channel 深度。
一句话总结
Go GMP 调度深入:Go 1.14+ 用信号抢占解决大循环不调度的问题;工作窃取:P 从其他 P 偷 G,避免全局队列竞争;sysmon 是监控线程,做抢占检查、网络轮询、syscall 监控、GC 触发;M 阻塞在 syscall 时释放 P,其他 M 可以继续执行;容器环境注意 GOMAXPROCS 的适配,CPU 密集 = CPU 核心数,I/O 密集可以略大。