Go GMP 调度模型:Goroutine 如何被高效调度
你的 go func() 创建完后发生了什么?探秘 Go GMP 调度器
你写下
go func()只花了 1 毫秒,但 Go 运行时为了让它执行起来,背后跑了上万行调度代码。
从一个场景说起
先看一段人畜无害的代码:
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
runtime.GOMAXPROCS(2) // 用 2 个逻辑处理器
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 模拟一些计算
sum := 0
for j := 0; j < 1000000; j++ {
sum += j
}
fmt.Printf("goroutine %d done, sum=%d\n", id, sum)
}(i)
}
wg.Wait()
}
表面上看:main 函数起了 10 个 goroutine,等它们执行完就退出了。但你有没想过这些问题:
- 10 个 goroutine 在 2 个 P(逻辑处理器)上跑,怎么分配的?
- 所有 goroutine 都在抢一个线程?还是有多个线程?
- 如果一个 goroutine 卡在系统调用上了,后面的 goroutine 怎么办?
这些问题,都需要理解 Go 的 GMP 调度模型才能回答。
核心原理
为什么要造一个调度器?——历史包袱
在没有 goroutine 的时代,写高并发要么用进程(太重),要么用线程(也不少):
- 一个内核线程至少占用 1MB 栈空间
- 线程切换需要陷入内核态,涉及上下文保存、TLB 刷新等,约 1-3μs
- 高并发下几万个线程,光是栈空间就吃掉几十 GB
Go 的解决思路:把"线程"拆两层——用户态的 goroutine(G) 和 内核态的线程(M)。G 初始栈才 2KB,切换在用户态完成,代价不到 0.1μs。
但光拆还不够,调度器走了三次迭代:
阶段一:GM 模型(Go 1.0)
只有一个全局队列 + 一个锁。所有 M 从全局队列拿 G 都要抢锁。并发一高,锁竞争就炸了。
阶段二:引入 P(Go 1.1)
Dmitry Vyokov 引入了 Processor(P)—— 每个 P 维护自己的本地运行队列。M 先看本地队列,不够了才去全局"偷",大幅降低锁竞争。
阶段三:协作+信号抢占(Go 1.14)
加入基于信号的真正抢占机制,解决了大循环/GC 无限霸占线程的问题。
现在你看到的就是经过三次进化的 GMP 调度模型。
三个角色:G、M、P
| 角色 | 全称 | 职责 | 数量 |
|---|---|---|---|
| G | Goroutine | 协程,用户态轻量级执行单元 | 无上限(百万级别) |
| M | Machine | 系统线程,真正干活的 | 最多 10000,一般等于 P 数 |
| P | Processor | 逻辑处理器,调度"调度员" | 默认 = CPU 核数(GOMAXPROCS) |
每个角色都有对应的 Go 运行时结构体:
// src/runtime/runtime2.go(简化后)
// G —— 一个 goroutine
type g struct {
stack stack // 栈内存范围 [lo, hi)
stackguard0 uintptr // 栈扩张/抢占用
m *m // 当前执行的 M(可能为空)
sched gobuf // 调度上下文(sp, pc, 寄存器)
atomicstatus uint32 // 状态:_Gidle/_Grunnable/_Grunning...
goid int64
lockedm muintptr // 锁定的 M
}
// M —— 系统线程
type m struct {
g0 *g // 调度栈 G,每个 M 独有的"管家"
curg *g // 当前正在运行的 G
p puintptr // 绑定的 P
nextp puintptr // 备用 P(解绑时暂存)
spinning bool // 是否在"自旋"找活干
mcache *mcache // 内存缓存(从 P 搬过来的)
}
// P —— 逻辑处理器
type p struct {
status uint32 // _Pidle / _Prunning / _Psyscall ...
m muintptr // 绑定的 M
mcache *mcache // 内存分配缓存
runq [256]guintptr // 本地环形队列(256个槽位)
runqhead uint32 // 队头
runqtail uint32 // 队尾
runnext guintptr // 下一个优先执行的 G(比 runq 优先级高)
gFree struct { // 空闲 G 链表,复用避免重新分配
gList
n int32
}
}
三者的关系用一句话概括:
G 是要执行的代码,M 是执行代码的线程,P 是 M 的"调度员"——为 M 提供可运行的 G。
调度流程完整拆解
看一个 goroutine 从创建到执行的完整生命周期:
程序启动 → 初始化 g0 + m0 → 创建 main goroutine → 开始调度循环
步骤 1:初始化
运行时创建 m0(主线程)和 g0(调度 goroutine,负责调度其他人),然后把两者绑定。再根据 GOMAXPROCS 创建 N 个 P。
步骤 2:放入队列
当你写 go func(),新创建的 G 优先放入当前 P 的本地队列(runq,256 槽位环形队列)。如果本地队列满了,会把本地队列的前一半 + 新 G 一起打乱后放入全局队列。
步骤 3:M 获取 G
M 每次从绑定的 P 的本地队列取一个 G 执行(优先级:runnext > runq > 全局队列 > 偷其他 P)。
步骤 4:执行与归还
G 执行完毕后,M 切回 g0,再调度下一个 G。不断循环。
Work Stealing(任务窃取)
这是 GMP 调度器的灵魂机制。
核心逻辑:当一个 M 发现自己的 P 的本地队列为空时,它不会闲着,而是:
- 先去全局队列捞一批 G(捞取数量 ≈
len(global)/GOMAXPROCS + 1,保证公平) - 如果全局也空了,随机挑一个别的 P,偷它本地队列的一半 G
- 还是空?M 进入自旋状态(spinning),过一会儿再试
// 伪代码:findrunnable 的核心逻辑
func findrunnable() (gp *g) {
// 1. 先看本地 runq
if gp := runqget(_g_.m.p.ptr()); gp != nil {
return gp
}
// 2. 尝试从全局队列获取
if sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(_g_.m.p.ptr(), 0)
unlock(&sched.lock)
if gp != nil {
return gp
}
}
// 3. 偷!随机选一个 P,偷它一半
for i := 0; i < 4; i++ {
for _, pp := range allp {
if gp := runqsteal(pp, _g_.m.p.ptr()); gp != nil {
return gp
}
}
}
// 4. 真没了,进入自旋或休眠
stopm()
goto top // 唤醒后重新找
}
这个机制保证了:只要还有可运行的 G,就不会有无事可做的 M,最大化利用 CPU。
Hand Off(交接机制)
想象一个场景:G1 正在执行一个 系统调用(比如 read() 读文件),阻塞了。
如果是传统的线程模型,这个线程就挂在那了,它绑定的 P 上的其他 G 也跟着遭殃。
GMP 的 Hand Off 机制解决了这个问题:
① G1 发生系统调用 → M1 随 G1 一起阻塞
② P 检测到 M1 阻塞 → 将 P 与 M1 解绑
③ P 从空闲 M 列表拿一个 M2(或者新建一个),绑定后继续执行
④ G1 系统调用完成 → M1 苏醒,尝试找回原来的 P
⑤ 如果 P 还在 → 重新绑定 M1,继续执行 G1
⑥ 如果 P 已被占用 → G1 放入全局队列,M1 进入空闲列表
看个代码例子会更直观:
// 模拟 syscall 阻塞场景
package main
import (
"fmt"
"os"
"time"
)
func main() {
// 用 1 个 P,方便观察
// runtime.GOMAXPROCS(1) // 解除注释试试
go func() {
// 这个 G 会发生 syscall(读文件)
f, _ := os.Open("/dev/zero")
buf := make([]byte, 1024)
for i := 0; i < 100; i++ {
f.Read(buf) // 系统调用,不会阻塞 M 太长时间
}
fmt.Println("reader done")
}()
go func() {
// 纯计算的 G
sum := 0
for i := 0; i < 10000000; i++ {
sum += i
}
fmt.Println("calculator done")
}()
time.Sleep(1 * time.Second)
}
当 f.Read() 发生时,M 进入内核态。如果是纯计算 goroutine 在另一个 P 上,它完全不受影响。这就是 Hand Off 的最大价值:系统调用不是并发瓶颈。
深入细节
G 的状态机
一个 goroutine 在不同阶段有 9 种状态,最核心的 6 种:
┌─────────┐
│ _Gidle │ ← 刚分配,未初始化
└────┬────┘
│
┌────▼────┐
│_Grunnable│ ← 放入队列,等待执行
└────┬────┘
│
┌────▼────┐
│_Grunning │ ← 正在 M 上执行
└──┬──┬───┘
┌────────┘ └────────┐
┌────▼────┐ ┌────▼────┐
│_Gwaiting│ │_Gsyscall│
│ 等待事件 │ │ 系统调用 │
└────┬────┘ └────┬────┘
└────────┬───────────┘
┌────▼────┐
│_Grunnable│ ← 条件满足,重新排队
└────┬────┘
│
┌────▼────┐
│ _Gdead │ ← 执行完毕,等待回收
└─────────┘
一个关键细节:channel 阻塞不会阻塞 M。只有系统调用(文件 I/O、网络 syscall 等)才会让 M 一起阻塞。channel 操作本质是内存拷贝,不涉及内核,所以 goroutine 进入 _Gwaiting 时,M 直接去干别的活。
P 的五种状态
| 状态 | 含义 |
|---|---|
_Pidle |
空闲,没有绑定 M |
_Prunning |
正在运行,绑定了 M |
_Psyscall |
绑定的 M 正在系统调用 |
_Pgcstop |
GC 阶段暂停 |
_Pdead |
已废弃 |
GC 发生时,所有 P 都会被设置成 _Pgcstop,等 GC 完成后再恢复。这也是 Go 1.14 之前 STW(Stop The World)能卡住几百毫秒的原因——GC 需要等待所有 M 都把 G 停下来。
基于信号的抢占(Go 1.14+)
在 Go 1.14 之前,GMP 是"协作式抢占"——只有当 G 主动让出(比如函数调用、channel 操作)时,才会触发调度。但如果是这样的代码:
func endless() {
for {} // 死循环,永远不调度
}
这个 goroutine 会霸占线程不放,其他 goroutine 全部饿死。
Go 1.14 引入了基于信号的真正抢占:
- 监控线程(sysmon)定期检查每个运行中的 G
- 如果某个 G 运行超过 10ms,发送
SIGURG信号给其所在线程 - 信号处理函数将 G 的
stackguard0置为特殊值 - G 在下次指令边界被"骗"进调度函数,主动让出
// 验证抢占:跑个死循环,看其他 G 能否执行
package main
import (
"fmt"
"time"
)
func main() {
go func() {
for {
fmt.Println("alive!")
time.Sleep(500 * time.Millisecond)
}
}()
// 死循环,但会被抢占
go func() {
for {
// Go 1.14 之后,空循环也会被信号打断
}
}()
time.Sleep(3 * time.Second)
}
这段代码在 Go 1.14+ 上,fmt.Println 能正常输出。在 1.14 之前,第二个 for{} 会锁死整个线程,"alive!" 永远打不出来。
全局队列的公平性保障
GMP 调度器并不是"本地队列优先"的一言堂。为了保证公平性,每次从全局队列获取 G 时有一个概率控制:
// 如果 P 已经连续调度了 61 次,强制检查一次全局队列
if _g_.m.p.ptr().schedtick%61 == 0 {
if gp := globrunqget(_g_.m.p.ptr(), 1); gp != nil {
return gp
}
}
这意味着每 61 次本地调度,至少有 1 次会去全局队列看看。防止全局队列中的 G 被饿死。
最佳实践
1. GOMAXPROCS 怎么设?
// 最佳实践:不设,保持默认
// 默认 = CPU 核数,大部分场景最优
但在容器环境(K8s、Docker)中要注意:
// 容器场景:必须显式设置!
// 因为 runtime.NumCPU() 检测的是宿主机的核数,不是容器的 limit
import "runtime"
// 推荐用 uber/automaxprocs 自动处理
import _ "go.uber.org/automaxprocs"
2. 不要滥用 goroutine
GMP 再好,也不是无代价的。每个 G 至少占用 2KB+ 元数据:
// ❌ 不要这样
for _, item := range hugeList {
go process(item) // 百万级 goroutine 会压垮调度器
}
// ✅ 用 worker pool 限制
sem := make(chan struct{}, 100) // 限制并发 100
for _, item := range hugeList {
sem <- struct{}{}
go func(it Item) {
defer func() { <-sem }()
process(it)
}(item)
}
3. 注意系统调用密集型场景
频繁的系统调用会导致 P 频繁 Hand Off,增加开销:
// ❌ 高频系统调用,每个都创建 goroutine
for {
go func() {
data, _ := os.ReadFile("/some/file") // syscall 频繁
process(data)
}()
}
// ✅ 用 goroutine 池 + 批量读取
4. 用 GODEBUG 观察调度
调试时可以用环境变量观察 GMP 的工作状态:
# 每 1000ms 输出调度摘要
GODEBUG=schedtrace=1000 go run main.go
# 输出示例:
# SCHED 1004ms: gomaxprocs=4 idleprocs=2 threads=5 spinningthreads=1 idlethreads=1 runqueue=0 [0 3 2 0]
# ↑P数量 ↑空闲P数 ↑M总数 ↑自旋M数 ↑空闲M数 ↑全局G数 ↑每个P的本地G数
5. 避免 goroutine 泄漏
// ❌ 泄漏!G 永远阻塞,M 被"套牢"
go func() {
<-neverClosed // 没人写数据,永远不返回
}()
// ✅ 用 context 超时控制
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go func() {
select {
case <-ctx.Done():
return
case val := <-someChan:
process(val)
}
}()
总结
GMP 调度模型是 Go 并发编程的基石。它用三个核心思路解决了传统线程模型的困境:
- P 的引入将全局锁竞争降低了几个数量级,每个 P 的本地队列可以无锁访问,只有跨 P 偷 G 时才涉及少量锁操作
- Work Stealing 保证了所有系统资源都被充分利用——没有 M 会白白闲着
- Hand Off 让系统调用不再是并发的死敌,阻塞一个 M 不影响其他 G 继续运行
理解了 GMP,你就理解了为什么 Go 敢说"十万 goroutine 洒洒水"——这背后是一个精心设计、经过十多年迭代的用户态调度器在默默支撑。
延伸阅读方向
- 深入
src/runtime/proc.go中的schedule()、findrunnable()、execute()三个核心函数,看调度循环的源码级实现 - 对比学习 Kubernetes Scheduler 的调度策略,你会发现"队列 + 抢占 + 窃取"的设计思路在大型系统中无处不在
- 阅读 Go 官方提案 Proposal: Non-cooperative goroutine preemption 深入了解基于信号的抢占式调度设计细节