15.1 GMP 调度模型

一句话

Go 运行时把 “M 个内核线程” 与 “N 个 goroutine” 之间插了一层 P(逻辑处理器):P 持有本地可运行队列与内存缓存,让调度在用户态完成,从而既避免每次切换都陷入内核,又能用 work stealing 保持负载均衡。

三个角色

角色 是什么 关键点
G goroutine(2KB 初始栈 + 状态) 创建成本极低,可百万级
M 内核线程(machine) 真正执行代码;阻塞时会被换下
P 逻辑处理器(数量 = GOMAXPROCS) 持有本地队列(上限 256)与 mcache
M ←绑定→ P ←取 G→ 执行
         └→ 本地队列;空了就去全局队列或偷别的 P 的(work stealing)

三种 “卡住” 的处理方式

情况 运行时动作 后果
网络 IO 阻塞 交给 netpoller,M 去跑别的 G 不占线程
系统调用阻塞 P 与 M 解绑(handoff),另找/新建 M 线程数可能上涨
长时间纯计算 sysmon 触发抢占(Go 1.14+ 异步抢占) 避免饿死其他 G

GOMAXPROCS 决定 “同时跑用户代码的 P 数量”,默认等于 CPU 核数;容器里应显式设置(否则可能按宿主机核数)。

观测

想看什么 手段
goroutine 数量与栈 pprof 的 goroutine profile(见 15.5 pprof (CPU / 内存 / 阻塞 / 互斥))
调度延迟 GODEBUG=schedtrace=1000、runtime/metrics
P 是否用满 GOMAXPROCS、容器 CPU limit(见 6.3 namespace (隔离) 与 cgroups (限制))

goroutine 与栈的开销见 15.2 goroutine 与栈管理。

落到数字:调度参数与观测

项 默认/量级 说明
GOMAXPROCS = CPU 核数 应改为 cgroup 配额
本地队列 256 个 G 满了溢到全局队列
sysmon 检查周期 约 10ms 量级 触发抢占与网络轮询
异步抢占 Go 1.14+ 纯计算循环也能被切
创建 goroutine 约 0.2–0.4μs 比线程便宜两个量级
GODEBUG=schedtrace=1000 ./app      # 每秒打印:gomaxprocs、idle P、runqueue 长度
GODEBUG=scheddetail=1,schedtrace=1000 ./app | head -30

反模式

反模式 后果
在容器里不设 GOMAXPROCS 按宿主机核数开 P,配额下大量节流
用 goroutine 池 “省创建” 创建成本本就极低,池反而增加复杂度
CPU 密集循环不检查取消 抢占粒度内其他 G 被延迟
无限制地 go func() goroutine 泄漏,内存与调度双爆

三条铁律

🔑 P 的存在是为了 “用户态调度 + 本地缓存”,不是 “多一层线程”。

🔑 GOMAXPROCS 要跟 cgroup 配额对齐,不是跟物理核数。

🔑 CPU 密集任务要能被打断(检查 context / 让出),否则拖慢全局。

自检

Q1 为什么 Go 需要 P,而不是让 M 直接从全局队列取 G?

▸ 答案:因为全局队列要加锁,高并发下锁竞争会毁掉扩展性。P 让每个 M 有独立的本地队列与 mcache,大多数调度与分配都在无锁路径上完成;只有本地队列空/满时才碰全局结构。

Q2 一个 goroutine 做了阻塞式系统调用,其他 goroutine 会被卡住吗?

▸ 答案:不会。运行时会把这个 M 与 P 解绑(handoff),P 交给其他 M 继续跑本地队列;阻塞的那个 M 回来后尝试获取一个 P,拿不到就把 G 放回全局队列并休眠。代价是线程数可能增加。

Q3 容器 limits.cpu=2,GOMAXPROCS 却是 16,会怎样?

▸ 答案:16 个 P 抢 2 核的配额,CFS 周期内被反复节流,表现为延迟抖动与吞吐下降。修法是设置 GOMAXPROCS=2(或用 automaxprocs 按 cgroup 自动设置),让 P 数与实际可用 CPU 匹配。

Q4 什么情况下会出现 “goroutine 数量持续上涨”?

▸ 答案:常见三类:① 每次请求 go func() 但没有退出条件(泄漏);② channel 写入阻塞且无人消费;③ 网络连接未关闭导致读循环挂住。用 goroutine profile 按栈聚合,能直接看出泄漏点。

Q5 抢占在 Go 1.14 之前为什么有问题?

▸ 答案:早期只有协作式抢占(在函数调用检查点让出),纯计算的循环没有函数调用,就无法被打断,导致调度延迟可达秒级。Go 1.14 引入基于信号的异步抢占,使长循环也能在毫秒级被切走。

Q6 schedtrace 里 runqueue 长期大于 0、idleprocs 为 0 说明什么?

▸ 答案:说明 CPU 已饱和:P 全在跑、还有 G 排队。此时加机器/加 P 有意义;如果 idleprocs 很高但吞吐上不去,问题在 “G 都在阻塞”(锁、IO、channel),要查阻塞画像而不是扩 CPU。

关联