Go GMP 调度模型小记 #
按照 Go 1.26.3 的 runtime 源码看 GMP。
go f()
-> 创建 G
-> 放进 P 的运行队列
-> M 绑定 P
-> schedule 找到 G
-> execute 切到 G
-> G 阻塞 / 让出 / syscall / 结束
-> 回到 schedule源码入口 #
runtime/runtime2.go:g、m、p、schedt的结构定义。runtime/proc.go:调度主逻辑,newproc、schedule、findRunnable、execute、gopark、goready、runqsteal都在这里。runtime/chan.go:channel 阻塞和唤醒。internal/poll/fd_unix.go:网络 fd 读写。runtime/netpoll.go:网络 IO 等待。runtime/netpoll_epoll.go:Linux epoll。
GMP 分别是什么 #
先记一句:
G:goroutine,要被调度的执行单元。M:machine,对应 OS thread,真正执行代码。P:processor,执行 Go 代码必须持有的资源和令牌。
一个 M 想运行 Go 代码,必须先绑定 P。
G: goroutine 运行现场
stack / sched / status / waitreason / m
M: OS thread
g0 / curg / p / spinning / park
P: Go 执行权
runq / runnext / timers / mcache / gc workGOMAXPROCS 控制的是 P 的数量,不是 M 的数量。M 可能因为 syscall、cgo、runtime 后台线程变多,但是同一时刻真正并行跑 Go 代码的数量,主要看 P。
G 的状态 #
runtime2.go 里能看到 G 的状态。
| 状态 | 含义 |
|---|---|
_Grunnable |
可以运行,正在队列里等 M/P |
_Grunning |
正在某个 M 上运行 |
_Gwaiting |
被 runtime 挂起,等 channel、timer、IO 等事件 |
_Gsyscall |
正在 syscall 里 |
_Gpreempted |
被抢占 |
_Gdead |
已结束或者可复用 |
理解 GMP,其实就是理解 G 怎么在这些状态之间变化。
_Grunnable -> _Grunning -> _Gwaiting
-> _Gsyscall
-> _Gdead
-> _Grunnable程序启动时 GMP 怎么形成 #
源码位置:runtime/proc.go
启动时 runtime 会先初始化调度器:
schedinit
-> 读取 GOMAXPROCS
-> procresize
-> 初始化 P
-> 创建 main goroutine
-> mstart
-> schedule大概是这样:
func schedinit() {
nprocs := getGOMAXPROCS()
procresize(nprocs)
}
func procresize(nprocs int32) {
// 创建或调整 allp
// 当前 M 拿到一个 P
// 其他 P 放到 idle P 列表
}这里有几个点:
m0是程序启动时的第一个 M。- 每个 M 都有一个
g0,调度器代码跑在g0上。 runtime.main是普通 G,也要被调度执行。- P 初始化完成后,调度器才有地方放 runnable G。
go f() 之后发生什么
#
源码位置:runtime/proc.go
写下:
go f()编译后会进入 runtime.newproc。
go f()
-> newproc
-> newproc1
-> runqput
-> wakep删减版:
func newproc(fn *funcval) {
systemstack(func() {
gp := newproc1(fn)
pp := getg().m.p.ptr()
// 放到当前 P 的本地队列,优先尝试 runnext。
runqput(pp, gp, true)
// 有空闲 P 时,尝试唤醒一个 M 来跑。
wakep()
})
}newproc1 做的事:
func newproc1(fn *funcval) *g {
gp := gfget(currentP())
if gp == nil {
gp = malg(stackMin)
}
// 初始化 G 的栈、入口函数、调度现场。
gp.sched.pc = funcPC(fn)
gp.sched.sp = newStackSP(gp)
gp.status = _Grunnable
return gp
}所以 go f() 并不是马上执行 f,只是创建一个 _Grunnable 的 G,然后放进队列。
P 的本地队列和全局队列 #
Go 调度器不是所有 G 都丢进一个全局队列。
主要有三处:
| 队列 | 位置 | 作用 |
|---|---|---|
p.runnext |
单个 P | 下一个优先运行的 G |
p.runq |
单个 P | P 的本地 runnable 队列 |
sched.runq |
全局 | 溢出、公平性、跨 P 注入 |
本地队列快,全局队列用来兜底。
runqput 大概是:
func runqput(pp *p, gp *g, next bool) {
if next {
old := pp.runnext
pp.runnext = gp
if old == nil {
return
}
gp = old
}
if pp.runqNotFull() {
pp.runq.push(gp)
return
}
// 本地队列满了,搬一批到全局队列。
runqputslow(pp, gp)
}runqget 则反过来:
func runqget(pp *p) (*g, bool) {
if gp := pp.runnext; gp != nil {
pp.runnext = nil
return gp, true
}
if gp := pp.runq.pop(); gp != nil {
return gp, false
}
return nil, false
}这里的 inheritTime 可以先简单理解成:从 runnext 取出来的 G 倾向继承当前时间片,保持局部性。
M 怎么找到 G 执行 #
源码位置:runtime/proc.go
M 真正跑调度循环的地方是 schedule。
M 持有 P
-> schedule
-> findRunnable
-> execute
-> gogo
-> 切到 G 的栈开始跑schedule 删减版:
func schedule() {
mp := getg().m
top:
gp, inheritTime, tryWakeP := findRunnable()
if tryWakeP {
wakep()
}
if gp.lockedm != nil {
startlockedm(gp)
goto top
}
execute(gp, inheritTime)
}findRunnable 是重点,它不是只查一个队列。
findRunnable
-> GC / trace / safepoint 任务
-> 定期查全局队列,避免饥饿
-> 当前 P 的 runnext / runq
-> 全局队列
-> netpoll 非阻塞检查
-> stealWork 从别的 P 偷
-> 仍然没有,M 休眠或者阻塞在 netpoll删减版:
func findRunnable() (*g, bool, bool) {
pp := getg().m.p.ptr()
if gp := checkGlobalRunqSometimes(pp); gp != nil {
return gp, false, false
}
if gp, inherit := runqget(pp); gp != nil {
return gp, inherit, false
}
if gp := globrunqget(pp); gp != nil {
return gp, false, false
}
if gp := netpollCheck(); gp != nil {
return gp, false, false
}
if gp := stealWork(pp); gp != nil {
return gp, false, false
}
return stopOrPollUntilWork()
}execute 是怎么执行 G 的 #
源码位置:runtime/proc.go
execute 负责把 G 绑定到当前 M 上,然后切换到 G 的执行现场。
func execute(gp *g, inheritTime bool) {
mp := getg().m
mp.curg = gp
gp.m = mp
gp.status = _Grunning
if !inheritTime {
mp.p.ptr().schedtick++
}
// 从 g0 切到 gp 的栈。
// 恢复 gp.sched 里保存的 pc/sp。
gogo(&gp.sched)
}这里要注意:
- 调度器代码跑在
m.g0栈上。 - 用户 goroutine 跑在自己的 G 栈上。
gogo之后就不是普通函数调用了,而是恢复 G 的 PC/SP。- G 从
_Grunnable变成_Grunning。
goroutine 怎么切换 #
goroutine 切换不是 OS 线程切换。
Go 的切换大概是:
当前 G 保存现场
-> mcall 切到 M.g0
-> runtime 修改 G 状态
-> dropg 解绑 M 和 G
-> schedule 找下一个 G
-> execute
-> gogo 切到下一个 G阻塞切换:gopark #
源码位置:runtime/proc.go
channel 没数据、timer 没到、IO 没 ready,都会让当前 G park。
G 正在运行
-> gopark
-> mcall(park_m)
-> 切到 g0
-> G: _Grunning -> _Gwaiting
-> dropg
-> schedule删减版:
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason) {
gp := getg().m.curg
gp.waitreason = reason
// 保存当前 G 的现场,然后切到 g0 执行 park_m。
mcall(park_m)
}
func park_m(gp *g) {
gp.status = _Gwaiting
dropg()
if unlockf != nil {
unlockf(gp, lock)
}
schedule()
}重点:gopark park 的是 G,不是 M 和 P。
当前 G 睡下后,M 带着 P 继续调度别的 G。
唤醒切换:goready #
源码位置:runtime/proc.go
事件到了之后,等待的 G 会被唤醒。
事件发生
-> goready(gp)
-> G: _Gwaiting -> _Grunnable
-> runqput
-> wakep删减版:
func goready(gp *g) {
systemstack(func() {
ready(gp)
})
}
func ready(gp *g) {
gp.status = _Grunnable
runqput(currentP(), gp, true)
wakep()
}goready 只是把 G 放回队列,不代表马上运行。后面还是要等某个 M 通过 findRunnable 把它取出来。
主动让出:Gosched #
runtime.Gosched() 是当前 G 主动让出。
Gosched
-> 当前 G: _Grunning -> _Grunnable
-> 放回队列
-> schedule它和 gopark 的区别是:Gosched 没有等待事件,所以 G 还是 runnable。
执行结束:goexit #
goroutine 函数返回后:
G return
-> goexit
-> goexit0
-> G: _Grunning -> _Gdead
-> dropg
-> gfput 等待复用
-> schedulework stealing 是怎么偷的 #
源码位置:runtime/proc.go
如果当前 P 没有本地 G,全局队列也没拿到,netpoll 也没 ready,就会尝试偷。
当前 P 没活
-> stealWork
-> 随机遍历其他 P
-> runqsteal
-> 从 victim P 偷一批 G
-> 当前 M 立刻执行其中一个stealWork 大概是:
func stealWork(pp *p) *g {
order := randomOrder(allp)
for try := 0; try < stealTries; try++ {
for _, victim := range order {
if victim == pp {
continue
}
// 后面的尝试会顺带看 timer。
checkTimersIfNeeded(victim, try)
if gp := runqsteal(pp, victim); gp != nil {
return gp
}
}
}
return nil
}runqsteal 的重点是偷一批,不是偷一个。
func runqsteal(thief, victim *p) *g {
batch := runqgrab(victim)
if len(batch) == 0 {
return nil
}
// 拿一个出来马上执行。
gp := batch[len(batch)-1]
batch = batch[:len(batch)-1]
// 其他的放进 thief P 的本地队列。
for _, stolen := range batch {
runqput(thief, stolen, false)
}
return gp
}为什么通常偷一半:
- 偷一个太少,会频繁跨 P 抢。
- 全偷走太重,会让 victim P 变空。
- 偷一批可以让空闲 P 马上有活。
- victim P 也保留一部分局部性。
runnext 不要简单理解成普通队列。它是优先运行槽,主要为了局部性;偷取主要针对普通 runq,源码里对 runnext 有额外条件,不是第一目标。
M 为什么有 spinning #
源码位置:runtime/proc.go
spinning M 的意思是:这个 M 暂时没找到活,但它还不睡,先主动找一圈。
如果每次有新 G 都唤醒线程,线程会太多;如果太保守,又可能有 G ready 了没人跑。所以 Go 用 nmspinning 控制正在找活的 M 数量。
wakep 大概是:
func wakep() {
if sched.nmspinning != 0 {
return
}
if !cas(&sched.nmspinning, 0, 1) {
return
}
startm(nil, true)
}spinning M 的流程:
spinning M
-> 查本地队列
-> 查全局队列
-> 查 netpoll
-> stealWork
-> 还没有
-> resetspinning
-> 再检查一次
-> releasep
-> stopm 睡眠这里“再检查一次”很重要。
如果 M 正准备睡,另一个线程刚好提交了新 G,并且看到还有 spinning M 就没有唤醒新线程,那这个 G 可能没人跑。Go 在 spinning M 进入睡眠前会重新检查工作来源,避免这个竞态。
网络 IO 等待发生了什么 #
源码位置:
internal/poll/fd_unix.gointernal/poll/fd_poll_runtime.goruntime/netpoll.goruntime/netpoll_epoll.go
以 Linux 下 conn.Read(buf) 没数据为例。
conn.Read
-> internal/poll.(*FD).Read
-> syscall.Read
-> EAGAIN
-> pd.waitRead
-> runtime_pollWait
-> netpollblock
-> gopark(waitReasonIOWait)FD.Read 大概是:
func (fd *FD) Read(buf []byte) (int, error) {
fd.readLock()
defer fd.readUnlock()
fd.pd.prepareRead()
for {
n, err := syscall.Read(fd.Sysfd, buf)
if err == EAGAIN && fd.pd.pollable() {
if fd.pd.waitRead() == nil {
continue
}
}
return n, err
}
}waitRead 会进 runtime:
func (pd *pollDesc) waitRead() error {
res := runtime_pollWait(pd.runtimeCtx, modeRead)
return convertErr(res)
}runtime 里会把当前 G 挂到 pollDesc 上。
func runtime_pollWait(pd *pollDesc, mode int) int {
for {
if err := netpollcheckerr(pd, mode); err != pollNoError {
return err
}
if netpollblock(pd, mode) {
return pollNoError
}
}
}pollDesc 有两个等待槽:
rg:读等待的 G。wg:写等待的 G。
状态大概是:
pdNil : 没有 G 在等
pdWait : G 正准备登记
*g : 某个 G 正在等
pdReady : IO 已 readynetpollblock 大概是:
func netpollblock(pd *pollDesc, mode int) bool {
slot := pd.waitSlot(mode)
if slot == pdReady {
slot = pdNil
return true
}
slot = pdWait
// commit 阶段把 pdWait 换成当前 G。
gopark(netpollblockcommit, slot, waitReasonIOWait)
return consumeReady(slot)
}这个场景下:
G: _Grunning -> _Gwaiting
M: 不等 IO,回到 schedule
P: 继续给 M 跑其他 G所以网络 IO 等待不是 M 等,是 G 等。
epoll ready 后怎么回来 #
Linux 下 runtime 会用 epoll。
事件回来:
epoll_wait 返回
-> netpoll
-> netpollready
-> netpollunblock
-> 找到等待的 G
-> 组成 gList
-> injectglist
-> G 回到 runnable删减版:
func netpoll(delay int64) gList {
events := epollWait(delay)
var list gList
for _, ev := range events {
pd := ev.pollDesc
mode := ev.mode
netpollready(&list, pd, mode)
}
return list
}
func netpollready(list *gList, pd *pollDesc, mode int) {
if mode&modeRead != 0 {
if gp := netpollunblock(pd, modeRead); gp != nil {
list.push(gp)
}
}
if mode&modeWrite != 0 {
if gp := netpollunblock(pd, modeWrite); gp != nil {
list.push(gp)
}
}
}findRunnable 会检查 netpoll:
func findRunnable() *g {
if gp := runqget(currentP()); gp != nil {
return gp
}
if gp := globrunqget(currentP()); gp != nil {
return gp
}
if netpollHasWaiters() {
list := netpoll(0)
if !list.empty() {
injectglist(list.rest())
return list.first()
}
}
if gp := stealWork(currentP()); gp != nil {
return gp
}
return stopOrPollUntilWork()
}所以 IO 完成后的回流是:
fd readable
-> epoll_wait 收到事件
-> 找到 pollDesc.rg 上的 G
-> G: _Gwaiting -> _Grunnable
-> G 进入运行队列
-> 某个 M/P execute(G)
-> G 从 waitRead 返回
-> 重新 syscall.Readchannel 阻塞又是什么样 #
源码位置:runtime/chan.go
channel 等待也是 G park,只是等待来源不是 epoll,而是另一个 goroutine。
<-ch 没数据时:
chanrecv
-> buffer 没数据
-> sendq 没发送者
-> 创建 sudog
-> sudog.g = 当前 G
-> c.recvq.enqueue(sudog)
-> gopark(waitReasonChanReceive)删减版:
func chanrecv(c *hchan) {
lock(&c.lock)
if sg := c.sendq.dequeue(); sg != nil {
recvDirect(c, sg)
goready(sg.g)
unlock(&c.lock)
return
}
if c.qcount > 0 {
readFromBuffer(c)
unlock(&c.lock)
return
}
sg := acquireSudog()
sg.g = getg()
c.recvq.enqueue(sg)
gopark(chanparkcommit, &c.lock, waitReasonChanReceive)
}另一个 G 发送:
chansend
-> recvq 有等待者
-> 取出 sudog
-> 复制数据
-> goready(receiverG)这里的协作:
接收 G: _Grunning -> _Gwaiting
发送 G: 把数据交给接收者
接收 G: _Gwaiting -> _Grunnable
M/P: 继续按调度器执行channel 不需要 OS 线程睡眠,等待关系在 runtime 队列里。
timer 等待 #
time.Sleep 也是一样的思路。
time.Sleep
-> 创建 timer
-> gopark(waitReasonSleep)
-> timer 到期
-> goready(sleeping G)timer 和 P 关系很近。P 有自己的 timer 相关结构,findRunnable、stealWork 找不到普通 G 时,也会检查 timer,避免 timer 到期没人处理。
syscall / cgo 阻塞 #
网络 fd 一般是 non-blocking,所以走 netpoll。
但是普通 syscall、文件 IO、cgo 可能真的把 OS thread 卡住。这个场景的重点是:M 可以被卡住,但 P 不能一直浪费。
普通 syscall #
源码位置:runtime/proc.go
G 进入 syscall
-> G: _Grunning -> _Gsyscall
-> M 进入内核
-> syscall 很快返回:尽量继续用原来的 P
-> syscall 太久:sysmon 可能 retake P删减版:
func entersyscall() {
gp := getg()
pp := gp.m.p.ptr()
gp.syscallpc = getCallerPC()
gp.syscallsp = getCallerSP()
gp.m.oldp = pp
gp.status = _Gsyscall
}明确会阻塞的 syscall #
有些路径会更直接释放 P:
entersyscallblock
-> G: _Grunning -> _Gsyscall
-> releasep
-> handoffp
-> P 交给其他 M删减版:
func entersyscallblock() {
gp := getg()
gp.status = _Gsyscall
pp := releasep()
handoffp(pp)
}syscall 返回 #
返回时要重新拿 P。
syscall return
-> 尝试拿回 oldp
-> oldp 不行,尝试拿 idle P
-> 拿到 P:继续运行
-> 拿不到 P:G 放回队列删减版:
func exitsyscall() {
gp := getg()
if exitsyscallfast(gp.m.oldp) {
gp.status = _Grunning
return
}
mcall(exitsyscall0)
}
func exitsyscall0(gp *g) {
gp.status = _Grunnable
dropg()
globrunqput(gp)
schedule()
}所以 syscall 场景下:
G: 进入 _Gsyscall
M: 可能被内核卡住
P: 尽量被释放给其他 M这也是为什么你会看到线程数增加:旧 M 卡在 syscall 里,runtime 可能启动/唤醒别的 M 来接手 P。
sysmon 和抢占 #
源码位置:runtime/proc.go
sysmon 是 runtime 后台监控线程,不需要 P。
它会做几件事:
- 检查长时间 syscall,必要时 retake P。
- 检查长时间运行的 G,触发抢占。
- 处理 netpoll、timer、GC 相关工作。
- 协助 STW 和 safepoint。
抢占大概是:
sysmon
-> 发现某个 P 上的 G 跑太久
-> preemptone
-> 标记 gp.preempt
-> 设置 stackguard / 触发异步抢占
-> G 在安全点停下
-> 回到 runnable 或 preempted抢占的目标是 G,不是 P。P 要继续流动,不能被一个死循环 G 长时间占住。
几个场景放一起看 #
| 场景 | G | M | P |
|---|---|---|---|
go f() |
新 G 变 _Grunnable |
当前 M 继续跑 | G 进当前 P 队列 |
| 执行 G | _Grunnable -> _Grunning |
绑定 G 并执行 | 提供执行权 |
| channel 阻塞 | _Grunning -> _Gwaiting |
切回 g0 后 schedule | 继续跑别的 G |
| IO 等待 | _Grunning -> _Gwaiting |
不等 IO,继续 schedule | 继续跑别的 G |
| timer 等待 | _Grunning -> _Gwaiting |
继续 schedule | 维护 timer |
| syscall | _Grunning -> _Gsyscall |
可能卡在内核 | 可能释放/被 retake |
| work stealing | G 从别的 P 迁移 | 空闲 M 找到活 | 负载被摊开 |
| 抢占 | running G 被打断 | 回 scheduler | 继续跑其他 G |
最后串起来 #
可以把 GMP 记成三条线。
创建线:
go f()
-> newproc
-> newproc1
-> runqput
-> wakep执行线:
schedule
-> findRunnable
-> runqget / globrunqget / netpoll / stealWork
-> execute
-> gogo阻塞和唤醒线:
gopark
-> park_m
-> G: _Grunning -> _Gwaiting
-> schedule
事件发生
-> goready
-> G: _Gwaiting -> _Grunnable
-> runqput / injectglist
-> wakepGMP 的重点不是“G 跑在 M 上”这么一句话。
重点是:G 是可以被保存、挂起、恢复的执行现场;M 是真正跑代码的线程;P 是执行 Go 代码必须拿到的调度资源。runtime 做的事,就是让 G 在 runnable、running、waiting、syscall 之间变化,同时让 M 和 P 尽量不闲着。