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。
关联
- 前置:11.1 进程、线程与调度
- 并列:15.2 goroutine 与栈管理、15.8 并发编程模型 (线程 / 协程 / Actor / CSP)
- 下游:15.5 pprof (CPU / 内存 / 阻塞 / 互斥)、15.9 性能剖析工具链 (火焰图 / perf / eBPF)