Go 调度器与 G-M-P 模型
Go 运行时如何把大量 goroutine 多路复用到 OS 线程:G-M-P 教学模型、阻塞与网络轮询、GOMAXPROCS,以及规范不保证的实现边界。
[!info] related notes
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 执行普通用户 Go 代码需要绑定 P,部分运行时代码可无 P 执行——这是当前实现模型,不是语言规范条文。
最小可观察示例
先把示例放进可验证实验场景,再看代码:
场景:理解 GOMAXPROCS 与「CPU 忙 vs 阻塞」的调度成本
比较 GOMAXPROCS=1 与 =4 时,应让两次运行完成相同计算量,并保留结果,避免工作被消除。按墙钟忙等 50ms 会让被抢占的任务少算一些,不能用来证明 CPU 加速比。P 数限制用户 Go 代码并行度,实际速度还受 CPU 配额和其他负载影响。 本实验只建立直觉——G-M-P 是实现模型,不是语言规范同步原语。
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func work(seed uint64) uint64 {
x := seed
for i := 0; i < 5_000_000; i++ {
x = x*6364136223846793005 + 1
x ^= x >> 17
}
return x
}
func main() {
// 先看当前逻辑处理器与 CPU 数(理解“能并行执行多少 Go 代码”的上限直觉)
fmt.Println("GOMAXPROCS =", runtime.GOMAXPROCS(0))
fmt.Println("NumCPU =", runtime.NumCPU())
var wg sync.WaitGroup
results := make([]uint64, 4)
start := time.Now()
for i := range results {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 各 worker 只写自己独占的元素;等待结束后统一读取。
results[id] = work(uint64(id + 1))
}(i)
}
wg.Wait()
elapsed := time.Since(start)
fmt.Println("results:", results)
fmt.Println("elapsed:", elapsed)
}
保存为新模块中的 main.go,先执行 go mod init example.com/scheduler-demo。构建一次再运行,避免把编译时间计入比较:
go build -o scheduler-demo .
GOMAXPROCS=1 ./scheduler-demo
GOMAXPROCS=4 ./scheduler-demo
期望观察点:
- 两次 results 应相同,每个任务的固定计算量相同;结果正确性不依赖 P 数。
- P=1 时任务仍会交错执行;P=4 允许并行,但不保证更快或四倍加速。elapsed 只作本机观察,应多次测量并记录 CPU 配额。
- 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 是线程,不是额外 P,M 总数也不受 GOMAXPROCS 直接限制。
- 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 < 1 只查询
- Go 1.25+ 的默认策略考虑逻辑 CPU、CPU affinity 和 Linux cgroup CPU quota;旧语言版本的兼容默认值与 GODEBUG 可改变行为,不能只看工具链版本。
- 当前实现通常取这些限制的最小值,quota 非整数时向上取整;除逻辑 CPU/affinity 少于 2 外,不会因 quota 将默认值压到 2 以下。配额是吞吐限制,不等于可同时使用几个核。
- 显式设置正整数 GOMAXPROCS 环境变量或调用 GOMAXPROCS(n>0) 会关闭默认值自动更新;Go 1.25+ 的 SetDefaultGOMAXPROCS 可恢复运行时默认和更新行为。
- 调小只限制用户 Go 代码并行度,不是整个进程的 CPU 硬上限;cgo 与部分运行时工作不受同样限制。它也不是 goroutine/OS 线程数上限。
与正确并发的关系
调度器不提供 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
net/http/pprof 注册 handler,本身不会启动监听;应在受控调试入口上提供这些 handler,再观察 channel 等待栈。不要把导入包等同于 profile 已可访问。
本节总结
- Go 用用户态调度把海量 G 多路复用到少量 M;P 提供并行度与本地上下文。
- G-M-P 来自实现与设计文档(go11sched),不是规范 ABI。
- 阻塞、netpoll、抢占解释了高并发 I/O 行为;正确性仍靠内存模型。
- 工程重点:泄漏、并发度、阻塞点、race,而不是手写调度器。
自测题
- M 没有 P 时通常还能不能执行普通 Go 代码?
GOMAXPROCS限制的是什么?- 为什么大量网络连接不必创建等量 OS 线程?
- 调度器能否替代 mutex 保证共享内存可见性?
- 读调度源码前应区分哪两层文档?
参考答案
- 教学模型下:执行 Go 代码通常需要绑定 P;无 P 时 M 可能处于系统调用或其他状态(细节实现相关)。
- 当前实现的 P 数及普通用户 Go 代码的并行度;不是 goroutine、OS 线程或整个进程 CPU 使用量的硬上限。
- 运行时 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 — 异步抢占等(按需对照你的版本)