Go 的 GMP 调度器到底在干什么
读完这篇,你应该能回答三个问题:goroutine 创建后去了哪里,阻塞时发生了什么,以及为什么一个 goroutine 跑太久会被强制踢掉。基于 Go v1.19 runtime 源码。
目录
- 〇、前置知识:线程、协程与用户态
- [一、为什么要关心 GMP](#一、为什么要关心 GMP)
- 二、三个角色:G、M、P
- [三、调度:g0 怎么找到下一个 g](#三、调度:g0 怎么找到下一个 g)
- [四、让渡:g 主动让出 CPU](#四、让渡:g 主动让出 CPU)
- [五、抢占:sysmon 出手](#五、抢占:sysmon 出手)
- 六、一些设计上的取舍
〇、前置知识:线程、协程与用户态
聊 GMP 之前,先把几个基础概念理清楚。
内核态 vs 用户态
操作系统把 CPU 的运行分成两个权限级别:
- 内核态:操作系统内核运行的地方,能直接操作硬件、管理内存、调度线程。权限最高。
- 用户态 :应用程序运行的地方,不能直接碰硬件,需要通过系统调用(syscall)才能请内核帮忙做事。
为什么要分?安全。如果每个程序都能直接操作硬件,一个 bug 就能把整个系统搞崩。
线程(Thread)
线程是操作系统内核能调度的最小单元。注意是"内核调度"------创建线程、销毁线程、切换线程,都需要内核参与。
这意味着什么?线程切换是有开销的。内核要做:
- 保存当前线程的寄存器、栈指针等上下文
- 切换到新线程的页表
- 恢复新线程的上下文
- 可能还要刷 TLB(快表)
一次线程切换大概在微秒级别。听起来不多,但如果每秒切几万次,积少成多就很可观了。
而且线程的"重量"不只是切换。每个线程默认栈空间 1-8MB,创建一个线程就要分配这么大一块内存。开一万个线程,光栈就吃掉几十 GB。
协程(Coroutine)
协程是用户程序自己搞出来的调度单元,运行在用户态,不需要内核参与。
核心思路:线程是内核调度的,协程是应用程序自己调度的。一个线程上可以跑多个协程,协程之间的切换由程序自己完成,不用陷入内核。
协程切换时要做什么?跟线程差不多------保存寄存器、栈指针,恢复另一个协程的上下文。但因为全在用户态完成,不用切换页表、不用刷 TLB,开销比线程切换小一个数量级,通常在纳秒级。
协程的栈也可以很小。线程默认 1-8MB,协程可以只分配几 KB,按需增长。
所以协程的优势是:轻量、切换快、创建成本低。代价是需要程序自己实现调度逻辑,复杂度从操作系统转移到了应用程序。
goroutine
goroutine 是 Go 对协程的实现,但它不是简单的协程。Go 在原生协程的基础上做了两件事:
1. 栈可以动态扩缩
普通协程的栈大小通常是固定的。goroutine 初始栈只有 2KB,运行时如果不够了会自动扩容(分段栈 → 连续栈),用完了再缩回去。这样既省内存,又不用担心栈溢出。
2. 整个运行时都为它服务
这是 Go 跟其他语言最大的区别。C++ 的协程库可以实现协程调度,但 std::mutex 阻塞的是线程------一个协程卡在锁上,同线程的其他协程全跟着卡。Go 不一样,mutex、channel、网络 IO 这些东西在 runtime 层面都做了适配,阻塞粒度是 goroutine 而不是线程。
这就引出了 GMP:Go 需要一套调度系统来管理这些 goroutine,让它们高效地跑在操作系统线程上。这套系统就是 GMP。
一、为什么要关心 GMP
go func(){}() 写起来很爽,一个 goroutine 就跑起来了。初始栈 2KB,开百万个也不心疼。
但你有没有想过:操作系统根本不认识 goroutine,它只认线程。那谁在背后做调度?一个 goroutine 阻塞了,为什么不会把其他 goroutine 也拖死?
答案是 GMP。Go runtime 里有一套调度系统,专门干这事。
二、三个角色:G、M、P
GMP 就是三个东西:
| 组件 | 是什么 | 干什么 |
|---|---|---|
| G (Goroutine) | goroutine 本身 | 跑用户代码,有自己的栈和状态 |
| M (Machine) | 操作系统线程 | 真正干活的,在 g0 和 g 之间来回切换 |
| P (Processor) | 调度器 | 手里攥着一个 g 队列,决定下一个跑谁 |
简单说:M 是干活的人,P 是工头,G 是活。M 必须从 P 手里领活干,P 手里的活就是一堆 G。
g 的状态
一个 g 从创建到销毁,会经历这些状态:
_Gidle (0) 刚分配,还没初始化
_Grunnable (1) 就绪,在队列里等调度
_Grunning (2) 正在某个 M 上跑
_Gsyscall (3) 正在执行系统调用
_Gwaiting (4) 被阻塞了,等某个条件满足,通常是用户态阻塞,比如mutex和channel导致的阻塞
_Gdead (6) 跑完了,等着被回收复用
注意 _Gdead 不是真死------g 的对象还在,只是用户代码跑完了。下次有新的 go func() 时,runtime 可能直接复用这个 g 对象,省掉分配和初始化的开销。
状态流转路径:
newproc() → _Gidle → _Grunnable → execute() → _Grunning
│
┌─────────────────────────────────┤
│ │
gopark() goexit1()
(阻塞让渡) (正常结束)
│ │
▼ ▼
_Gwaiting _Gdead → 回收复用
│
goready()
│
▼
_Grunnable(回到就绪队列)
_Grunning 也可以走 reentersyscall() → _Gsyscall
│
exitsyscall()
│
▼
_Grunnable
g 存在哪
每个 P 有一个本地队列 lrq,容量 256,用 CAS 无锁访问。另外还有一个全局队列 grq,访问要加锁。
全局 schedt
┌──────────────────────────┐
│ grq(全局队列,加锁) │
└──────────────────────────┘
│ │ │
▼ ▼ ▼
P₀ P₁ P₂
[lrq] [lrq] [lrq]
256槽 256槽 256槽
CAS无锁 CAS无锁 CAS无锁
放 g 的逻辑很简单:先往自己 P 的 lrq 塞,满了才扔到全局 grq。取 g 的逻辑稍微复杂点,按这个顺序来:
- 先从自己 P 的 lrq 取(无锁)
- 从全局 grq 取(加锁)
- 检查有没有 IO 就绪的 g(netpoll)
- 从别的 P 的 lrq 偷(工作窃取)
有个细节值得记一下:每 61 次调度,强制先查一次 grq。61 是质数,故意跟 lrq 的 256 容量错开周期,防止全局队列饿死。
空闲时怎么办
没活干的时候,P 和 M 不会傻转 CPU。P 被扔进 schedt.pidle,M 被扔进 schedt.midle 然后暂停。等有新 g 被创建时,wakep() 把它们叫醒。
三、调度:g0 怎么找到下一个 g
每个 M 里都有一个特殊的 g0。它跟普通 g 不一样------普通 g 跑用户代码,g0 跑 runtime 的调度代码。g0 是 M 的伴生 goroutine,跟 M 一对一绑定,不由用户创建。
g 和 g0 是怎么切换的
M 的工作循环就是:g0 找 g → 切到 g 执行 → g 跑完了/让出了/被抢了 → 切回 g0 → 再找。
g0(调度)──gogo()──> g(跑用户代码)
↑ │
└── mcall()/systemstack ┘
切到 g 叫 gogo ,从 g 切回来叫 mcall 或 systemstack。这三个是 runtime 里最底层的切换原语:
| 方法 | 方向 | 谁调用 | 干什么 |
|---|---|---|---|
gogo(buf) |
g0 → g | g0 调 | 恢复 g 的寄存器和栈指针,跳过去执行 |
mcall(fn) |
g → g0 | g 调 | 保存 g 的上下文,切到 g0 执行 fn |
systemstack(fn) |
g → g0 → g | g 调 | 临时切到 g0 跑 fn,跑完自动切回 g |
mcall 和 systemstack 的区别:
mcall(fn):切到 g0 执行 fn,不会自动切回来 。fn 里通常会调schedule()去找新的 g,原来的 g 可能被挂起了或者结束了。systemstack(fn):切到 g0 执行 fn,fn 跑完自动切回原来的 g。适合做一些需要 g0 权限但不想放弃当前 g 的事情,比如创建新 goroutine。
一行 go func 背后发生了什么
go
go func() { /* 你的代码 */ }()
展开来看:
newproc()--- 创建 g 实例runqput()--- 塞进 lrq(满了就塞 grq)wakep()--- 叫醒空闲的 P/Mschedule()--- g0 开始找活干findRunnable()--- 按 lrq → grq → netpoll → steal 的顺序翻execute()--- 把 M 和 g 绑上,状态 runnable → runninggogo()--- 切换执行权,从 g0 跳到 g
其中 newproc 这一步有个细节:它是在当前 g 里调用的,但通过 systemstack 临时切到 g0 去执行。因为创建 g 需要操作 runtime 内部数据结构,这些操作需要在 g0 上完成。
go
func newproc(fn *funcval) {
gp := getg()
pc := getcallerpc()
systemstack(func() {
// 这里已经是 g0 在执行了
newg := newproc1(fn, gp, pc)
_p_ := getg().m.p.ptr()
runqput(_p_, newg, true)
if mainStarted {
wakep()
}
})
// systemstack 结束,自动切回原来的 g
}
工作窃取
自己 P 的队列空了怎么办?去偷别人的。
go
const stealTries = 4
for i := 0; i < stealTries; i++ {
for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
p2 := allp[enum.position()]
if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
return gp, inheritTime, false
}
}
}
起点随机,步长随机,避免一堆 P 同时去偷同一个目标。而且一次偷半队,不是偷一个------减少下次偷的频率。
四、让渡:g 主动让出 CPU
让渡就是 g 自己说"我不跑了",把执行权还给 g0。有三种情况。
跑完了
g 执行结束,自然退出:
g → goexit1() → mcall(goexit0) → g0
g0:状态 running → dead,清空 g,扔进 gfree 复用队列,调用 schedule()
主动让
调 runtime.Gosched(),相当于说"我先歇会儿,让别人跑":
g → Gosched() → mcall(gosched_m) → g0
g0:状态 running → runnable,扔进 grq,调用 schedule()
被阻塞了
mutex 加不上锁、channel 读不到数据,g 被卡住了:
g → gopark() → mcall(park_m) → g0
g0:状态 running → waiting,解绑 M,调用 schedule()
条件满足 → goready() → ready() → 状态 waiting → runnable,扔进 lrq/grq
注意一个区别:阻塞让渡后,g 不会 进就绪队列。它挂在某个 mutex 或 channel 的等待队列里,等条件满足了再由别人通过 goready 捞回来。
五、抢占:sysmon 出手
让渡是 g 自己让,抢占是别人来抢。
Go 启动时会创建一个全局唯一的 sysmon 线程,它不跑业务代码,只做三件事:
- 唤醒 IO 就绪的 g(netpoll)
- 抢占跑太久的 g(retake)
- 触发 GC
sysmon 大约每 10ms 轮询一次。
系统调用抢占
M 去做系统调用了,整个线程被内核占着,它手上 P 的队列里可能还有一堆 g 在等。
Go 的做法是:syscall 时把 P 和 M 解绑,P 记到 M 的 oldp 里。等 syscall 结束,先试试 oldp 还空着没,空着就直接复用;不行就找一个空闲 P;再不行就把 g 扔进 grq,M 暂停。
sysmon 发现某个 P 处于 syscall 状态,而且满足下面任一条件就动手:
- P 的 lrq 里有 g 在等
- syscall 已经超过 10ms
动手的方式是 handoffp:分配一个新 M 去接管这个 P。
运行超时抢占
某个 g 一口气跑了超过 10ms,sysmon 也会出手。这有两种方式。
协作式 :给 g 打个标记(stackguard0 = stackPreempt),等它下次栈扩张时自己发现,主动让出。
问题很明显:g 要是不触发栈扩张(比如死循环纯计算),就永远看不到这个标记。
非协作式(Go 1.14+) :直接发信号。sysmon 通过 pthread_kill 给目标 M 发 sigPreempt,信号处理函数篡改 g 的 PC 寄存器,把 asyncPreempt 函数插进去。g 恢复执行时,跑的已经是被偷梁换柱的代码了,最终走到 mcall(gopreempt_m) 把执行权交出来。
说白了就是:你不肯让,我就在你不知情的情况下把你的指令改了。
六、一些设计上的取舍
无锁优先。本地队列操作用 CAS,全局队列才加锁。热路径上能不锁就不锁,冷路径上才舍得锁。
全栈适配。Go 的并发优势不只是 goroutine 轻量。内存分配器给每个 P 准备了 mcache,mutex 和 channel 的阻塞粒度是 g 不是线程,网络 IO 用 netpoll 把 epoll 也控制在 g 粒度。整套运行时都是围着 GMP 设计的。
这跟 C++ 协程不一样。C++ 标准库的 std::mutex 阻塞的是线程,一个协程卡住了,同线程上的其他协程全跟着卡。要解决这个问题,得把所有并发原语都包一层协程感知的版本------工程量很大。
公平性。61 次调度强制查 grq 是防饥饿,随机化工作窃取是防热点,协作式+非协作式双保险是防抢占失效。
按需伸缩。P 和 M 空闲了就挂起来,有新活了再唤醒。不会空转 CPU。
附:G 的完整状态流转
newproc()
│
▼
_Gidle (0) 刚分配,还没初始化
│
▼
_Grunnable (1) 就绪,等调度
│
execute()
│
▼
_Grunning (2) 正在跑
│
├──── gopark() ──────> _Gwaiting (4) 等条件
│ │
│ goready()
│ │
│ ▼
│ _Grunnable (1) 回到就绪
│
├──── goexit1() ────> _Gdead (6) 跑完了,回收复用
│
└──── reentersyscall() ──> _Gsyscall (3) 系统调用
│
exitsyscall()
│
▼
_Grunnable (1)
写在最后
GMP 调度器的核心思路不复杂:用尽可能少的锁,把 g 均匀地分给 M 去跑。具体实现上有不少精巧的细节,但大方向就这一个。
理解 GMP 不是为了面试。写高并发代码的时候,知道 channel 阻塞时底层在做什么,知道为什么你的 goroutine 没有被及时抢占,排查问题时心里有底。