GMP 调度器:Go 并发的心脏是如何跳动的
一个 go func() 就能启动一个并发执行流,单机百万 goroutine 不是传说。支撑这一切的,是 Go 运行时的 GMP 调度器。今天把它的骨架拆开,并用代码亲手验证几个关键行为。
从 GM 到 GMP:多出来的 P 解决了什么
Go 早期是 GM 模型:一个全局 goroutine 队列 + 一把全局锁,所有线程抢锁取任务。问题一目了然------锁竞争、缓存局部性差、一个系统调用阻塞拖累全局。
2012 年 Dmitry Vyukov 的设计文档引入了 P(Processor),调度拆成三级:
| 角色 | 本质 | 职责 |
|---|---|---|
| G Goroutine | 执行流(栈 + PC + 寄存器现场) | 待执行的任务,初始栈 8KB |
| M Machine | OS 线程的封装 | 干活的手,必须绑定 P 才能执行 G |
| P Processor | 调度上下文 | 持有本地运行队列,数量 = GOMAXPROCS |
一句话记住三者的关系:P 的数量决定并行度(同一时刻最多 GOMAXPROCS 个 G 在 CPU 上跑),M 可以比 P 多(应对系统调用),G 几乎不限量。
队列结构:本地 + 全局的双层设计
- LRQ(本地运行队列):每个 P 一个容量 256 的环形队列。新建 G 优先放当前 P 的 LRQ------无锁操作,极快;
- GRQ(全局运行队列):LRQ 满了,一半 G(含新的)转移到 GRQ;LRQ 空了也从 GRQ 批量补充。
本地优先的设计把绝大部分调度操作变成无锁的,这是高并发下调度器不成为瓶颈的关键。
调度循环:M 找活的优先级
M 绑定 P 后进入调度循环,按这个顺序找下一个 G:
- 每 61 次调度检查一次 GRQ(防全局队列饥饿);
- 取 LRQ 队首;
- LRQ 空 → 从 GRQ 批量转移;
- 还没活 → 问 netpoll(网络轮询器)要就绪的网络 G;
- 再没活 → Work Stealing :随机挑一个 P,偷走它一半 的 G。
动手验证:调度器行为实测
go
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"time"
)
func main() {
// 1. GMP: G(goroutine) / M(OS 线程) / P(调度上下文, 数量 = GOMAXPROCS)
fmt.Printf("1. NumCPU = %d, GOMAXPROCS = %d, Goroutines = %d\n",
runtime.NumCPU(), runtime.GOMAXPROCS(0), runtime.NumGoroutine())
// 2. P 决定并行度: GOMAXPROCS=1 时多任务只能交替执行
old := runtime.GOMAXPROCS(1)
var counter int64
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 5; j++ {
atomic.AddInt64(&counter, 1)
time.Sleep(2 * time.Millisecond)
}
}()
}
wg.Wait()
runtime.GOMAXPROCS(old)
fmt.Printf("2. GOMAXPROCS=1 下 2 任务交替完成, counter = %d\n", atomic.LoadInt64(&counter))
// 3. Go 1.14+ 信号异步抢占: 纯计算死循环也会被让出
runtime.GOMAXPROCS(1)
done := make(chan struct{})
go func() {
x := 0
for i := 0; i < 3e7; i++ {
x += i
}
_ = x
close(done)
}()
select {
case <-done:
fmt.Println("3. GOMAXPROCS=1 下死循环 goroutine 完成 -> 异步抢占生效")
case <-time.After(3 * time.Second):
fmt.Println("3. 超时, 抢占失败(不应出现)")
}
runtime.GOMAXPROCS(old)
// 4. Work Stealing: 大量任务被多 P 分摊, 总耗时远低于串行
var wg2 sync.WaitGroup
const tasks = 200
t0 := time.Now()
for i := 0; i < tasks; i++ {
wg2.Add(1)
go func() {
defer wg2.Done()
time.Sleep(time.Millisecond)
}()
}
wg2.Wait()
fmt.Printf("4. %d 个 1ms 任务总耗时 %v (串行约 %dms)\n",
tasks, time.Since(t0).Round(time.Millisecond), tasks)
// 5. M 被复用: goroutine 数可远超 OS 线程数
fmt.Printf("5. 任务结束后 Goroutines 回落到 %d\n", runtime.NumGoroutine())
}
运行输出:
text
1. NumCPU = 16, GOMAXPROCS = 16, Goroutines = 1
2. GOMAXPROCS=1 下 2 任务交替完成, counter = 10
3. GOMAXPROCS=1 下死循环 goroutine 完成 -> 异步抢占生效
4. 200 个 1ms 任务总耗时 1ms (串行约 200ms)
5. 任务结束后 Goroutines 回落到 1
逐条解读
第 1 行 :16 核机器上 GOMAXPROCS = 16,这就是 P 的数量------16 个并行配额。
第 2 行 :把 P 压到 1,两个任务只能交替推进(并发但不并行),各自完成 5 次计数,合计 10。
第 3 行 :本次最有分量的验证。GOMAXPROCS=1 时跑一个不含任何函数调用、不含 channel 操作 的纯计算循环------在 Go 1.14 之前,这会饿死整个程序(唯一的 P 被永久霸占)。现在它能跑完并让出,靠的就是基于 SIGURG 信号的异步抢占:sysmon 发现 G 运行超 10ms,直接给运行它的 M 发信号,在信号处理里把 G 挂起。
第 4 行 :200 个 1ms 任务,串行要 200ms,实际只用了约 1ms------任务被铺到 16 个 P 上,空闲的 P 通过 Work Stealing 保持满载。偷取的细节值得记住:随机选受害者、一次偷一半------随机避免竞争热点,偷一半摊薄同步成本。
第 5 行 :200 个 goroutine 生灭之后,数量回落到 1。G 是轻量任务,M 是数量少得多的线程,M 被反复复用------这就是 goroutine 比线程便宜的直接证据。
阻塞时调度器怎么办
| 场景 | 调度器动作 |
|---|---|
| channel / mutex 阻塞 | G 挂起(gopark),P 原地调度下一个 G |
| 网络 I/O | G 注册到 netpoll 后挂起,P 不受影响------用户态非阻塞 |
| 系统调用(文件 I/O 等) | M 进 syscall 前与 P 解绑,P 被其他 M 接管;M 返回时抢不到 P 就把 G 丢回 GRQ 自己休眠 |
| 定时器到期 | G 被丢回运行队列等待调度 |
这张表藏着 Go 高并发 I/O 的全部秘密:网络 I/O 被 netpoll(epoll/kqueue 的封装)在用户态异步化。goroutine 看起来在同步阻塞地 read,实际上 OS 线程从没被拖住------写同步的代码,拿异步的性能。
sysmon:没有 P 的巡场者
sysmon 是一个特殊的 M,不绑定 P、永久循环,负责:
- 发现长跑 G(>10ms)→ 发起抢占;
- retake 陷入 syscall 太久的 P;
- netpoll 收割、到点强制 GC、定时器检查。
它是调度器的保安,保证没有任何 G/M/P 能长期脱管。
小结
| 认知 | 一句话 |
|---|---|
| 三要素 | G=任务,M=线程,P=并行度配额+本地队列(256) |
| 找活顺序 | 定期 GRQ → LRQ → GRQ 批量 → netpoll → 偷一半 |
| 均衡 | Work Stealing 随机偷一半,spinning 控制空转 |
| 抢占 | Go 1.14+ SIGURG 异步信号抢占,死循环不再饿死 |
| 阻塞 | syscall 时 M/P 解绑;网络 I/O 走 netpoll 用户态异步 |
| 监控 | sysmon 无 P 巡场:抢占、retake、netpoll、GC |