Go 调度器与 G-M-P 模型
Go 运行时如何把大量 goroutine 多路复用到 OS 线程:G-M-P 教学模型、阻塞与网络轮询、GOMAXPROCS,以及规范不保证的实现边界。
[!info] 关联笔记
Go 调度器与 G-M-P 模型
这个概念为什么出现
用户代码写的是:
go doWork()
操作系统提供的是线程。若 10 万个 goroutine 真对应 10 万线程,栈与调度成本不可接受。Go 运行时实现了用户态调度器,把大量 goroutine 多路复用到较少的 OS 线程上。
理解调度器是为了:
- 解释“goroutine 很便宜,但仍可能卡住线程”
- 理解
GOMAXPROCS、系统调用阻塞、网络轮询的大致位置 - 读懂 pprof 里的
runtime.栈与调度延迟现象 - 避免把调度细节当成同步原语
设计背景可读:Scalable Go Scheduler Design Doc(Go 1.1 时代的经典文档,模型此后有演化)。源码阅读指引见 Go 源码树中的 src/runtime/HACKING.md(随 SDK 版本)。
[!abstract] 一句话理解 运行时用 G(goroutine)、M(OS 线程)、P(逻辑处理器/调度上下文)组织多路复用:P 持有可运行 G 的队列,M 必须绑定 P 才能执行 Go 代码——这是当前实现模型,不是语言规范条文。
最小可观察示例
先把示例放进可验证实验场景,再看代码:
场景:理解 GOMAXPROCS 与「CPU 忙 vs 阻塞」的调度成本
压测时把 GOMAXPROCS=1 与默认多核对比,纯计算任务墙钟时间差一截;
换成 Sleep/channel 等待后差异又变小。工程师要关心:G 很多 ≠ 真·并行很多;P 的数量约束可同时执行 Go 代码的能力。
本实验只建立直觉——G-M-P 是实现模型,不是语言规范同步原语。
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
// 先看当前逻辑处理器与 CPU 数(理解“能并行执行多少 Go 代码”的上限直觉)
fmt.Println("GOMAXPROCS =", runtime.GOMAXPROCS(0))
fmt.Println("NumCPU =", runtime.NumCPU())
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// CPU 忙等:持续占用 P,更吃 GOMAXPROCS
// 业务类比:纯计算压缩、哈希、JSON 大对象编解码
end := time.Now().Add(50 * time.Millisecond)
for time.Now().Before(end) {
}
fmt.Println("done", id)
}(i)
}
wg.Wait()
}
建议观察:
GOMAXPROCS=1 go run .
GOMAXPROCS=4 go run .
期望观察点:
- 两种设置都能打印 4 个
done(正确性不依赖 P 数)。 GOMAXPROCS=1时四个忙等任务串行吃满一个 P,总墙钟往往更接近累加;=4时更易重叠。- 再把忙等改成
time.Sleep或 channel 等待:运行时可停 G,与纯 CPU 行为不同。
结合场景再看关注点
- 场景是理解成本与排障,不要用调度细节当锁。
- GOMAXPROCS 约束的是可同时跑 Go 代码的 P 规模(实现级直觉)。
- 阻塞 I/O 与 CPU 忙对 M/P 的占用模式不同——profile 前先分清工作类型。
核心模型
G、M、P 是什么(教学定义)
| 缩写 | 含义 | 直觉 |
|---|---|---|
| G | goroutine | 用户态轻量执行体:栈、状态、当前指令 |
| M | machine | OS 线程;真正被内核调度 |
| P | processor | 逻辑处理器:调度上下文、本地可运行队列、本地分配缓存等 |
flowchart LR
subgraph P0["P"]
Q0["local run queue"]
end
subgraph P1["P"]
Q1["local run queue"]
end
M0["M OS thread"] --> P0
M1["M OS thread"] --> P1
Q0 --> G1["G"]
Q0 --> G2["G"]
Q1 --> G3["G"]
GRQ["global run queue"] --> P0
GRQ --> P1
要点:
- 执行 Go 代码通常需要
M持有P。 - P 的数量大致由
GOMAXPROCS约束(实现细节:还有 sysmon 等特殊 M)。 - G 阻塞在 channel 等用户态原语上时,可与 M 解绑,M 去跑别的 G。
- G 进入某些系统调用时,M 可能被卡住;运行时会分离 P,让其他 M 拿走 P 继续跑(“handoff”类机制,实现演化中)。
为什么需要 P(相对旧模型)
早期设计讨论见 go11sched:引入 P 是为了:
- 限制同时执行 Go 代码的并行度
- 提供 per-P 本地队列,降低全局锁
- 为本地内存缓存等提供挂靠点
这直接关系到分配器的 mcache 直觉(分配器)。
调度触发点(经验列表)
G 可能在以下时机被调度出去(不完全表):
- channel / select / mutex 阻塞
time.Sleep、定时器- 系统调用
- 函数调用间隙的协作式抢占检查
- 基于信号的异步抢占(现代 Go 对忙循环更公平,实现细节)
- GC STW 或安全点相关协作
不要假设“没有函数调用的死循环永远独占核”——实现已强化抢占,但仍可能观察到不公平,正确性仍靠同步。
网络轮询(netpoll)
运行时把许多 socket I/O 接到 netpoller:G 等待数据时休眠,事件就绪后被唤醒。因此大量网络连接 ≠ 大量阻塞 OS 线程。这是 Go 网络服务能高并发的关键实现支柱之一。
GOMAXPROCS
runtime.GOMAXPROCS(n) // 返回先前值;n==0 只查询
- 默认通常与
NumCPU相关(版本/容器感知策略有过改进)。 - 调大:提高 CPU 型并行度,也可能增加调度与缓存开销。
- 调小:限制占用核数。
- 它不是“最大 goroutine 数”。
与正确并发的关系
调度器不提供 happens-before。两个 G 即使“看起来交替打印”,无同步仍可能 data race。必须用 channel、mutex、atomic 等。见 内存模型。
规范 vs 实现分界
| 语言/文档保证 | 实现(G-M-P) |
|---|---|
go 语句启动新 goroutine | G 结构体字段 |
| channel/select/mutex 语义 | 本地/全局运行队列算法 |
| 内存模型同步规则 | 抢占信号、sysmon |
| 程序应可被正确同步 | GOMAXPROCS 默认启发式 |
| — | work stealing 细节、自旋策略 |
明确: G-M-P 是阅读 runtime 与设计文档用的地图;应用正确性不得依赖“调度器会按某顺序运行”。
边界
-
CPU 密集 vs I/O 密集
前者吃GOMAXPROCS;后者更吃 netpoll 与阻塞路径设计。 -
cgo / 阻塞 syscall
长时间占住 M 会迫使运行时创建/调度更多线程,带来开销。见 cgo。 -
公平性
不保证严格 FIFO;饥饿通常靠抢占与调度策略缓解,但业务仍要避免忙等锁。 -
调试
GODEBUG=schedtrace=1000等输出是实现调试器,格式不稳定。 -
容器 CPU limit
历史版本对 cgroup CPU 配额的默认GOMAXPROCS行为有变化;部署时要确认当前版本策略。
常见误区
[!warning] 把 G-M-P 当规范背诵当面试唯一真相 要说清“实现模型,非语言保证”。
[!warning] 认为 goroutine 泄漏只是“多占一点内存” 泄漏的 G 可能卡在 channel、持有锁、钉住堆对象,拖垮服务。
[!warning] 用“睡一下让别的 G 跑”当同步
time.Sleep不是 happens-before 工具。
[!warning] 无界
go func打满调度器 要有并发度限制(worker 池、信号量、队列)。
[!warning] 忙等 for{} 空转当进度等待 浪费 CPU;用 channel/条件变量/事件。
工程实践
- 默认信任调度器,把精力放在:边界同步、避免泄漏、限制并发度。
- CPU 型任务测量
GOMAXPROCS敏感度;容器环境确认 CPU 配额。 - 阻塞库(同步磁盘、cgo、外部命令)隔离与超时。
- 画像:
pprof的goroutineprofile 查堵塞点;CPU profile 查忙等。 - 测试:
go test -race;并发测试不要依赖调度顺序。 - 阅读源码时配合 SDK 内
runtime/HACKING.md与设计文档,而不是过时博客字段名。
可验证实验
实验 A:GOMAXPROCS 与 CPU 任务
固定 N 个纯计算 goroutine,比较 GOMAXPROCS=1 与 =NumCPU 的 wall time。
实验 B:阻塞不吃满线程直觉
大量 time.Sleep 的 G vs 大量忙等 G,观察机器负载差异。
实验 C:goroutine 泄漏
func leak() {
ch := make(chan int)
go func() { <-ch }()
}
// 重复调用 leak,用 runtime.NumGoroutine() 观察上升
实验 D:pprof goroutine
在 HTTP 服务导入 net/http/pprof,打负载后查看阻塞在 channel 的栈。
本节总结
- Go 用用户态调度把海量 G 多路复用到少量 M;P 提供并行度与本地上下文。
- G-M-P 来自实现与设计文档(go11sched),不是规范 ABI。
- 阻塞、netpoll、抢占解释了高并发 I/O 行为;正确性仍靠内存模型。
- 工程重点:泄漏、并发度、阻塞点、race,而不是手写调度器。
自测题
- M 没有 P 时通常还能不能执行普通 Go 代码?
GOMAXPROCS限制的是什么?- 为什么大量网络连接不必创建等量 OS 线程?
- 调度器能否替代 mutex 保证共享内存可见性?
- 读调度源码前应区分哪两层文档?
参考答案
- 教学模型下:执行 Go 代码通常需要绑定 P;无 P 时 M 可能处于系统调用或其他状态(细节实现相关)。
- 大致是同时执行 Go 代码的逻辑并行度(P 数量相关),不是 goroutine 个数上限。
- 运行时 netpoll + G 休眠/唤醒,把 I/O 多路复用在少量线程上。
- 不能。必须使用内存模型认可的同步。
- 语言规范/内存模型(正确性)vs 设计文档与 runtime 实现(性能与机制)。
延伸阅读
- Scalable Go Scheduler Design Doc
- Go Memory Model
- runtime · GOMAXPROCS
- runtime · Gosched
- Diagnostics
- Effective Go — Concurrency
- Go 源码:
src/runtime/HACKING.md(本地 SDK,说明 runtime 开发约定与概念) - Go 1.14+ preemption 相关 release notes — 异步抢占等(按需对照你的版本)