Skip to main content
  1. 闲来无事 温雪煮茶/

Go GMP 调度模型小记

·10 mins·
Table of Contents

Go GMP 调度模型小记
#

按照 Go 1.26.3 的 runtime 源码看 GMP。

go f()
  -> 创建 G
  -> 放进 P 的运行队列
  -> M 绑定 P
  -> schedule 找到 G
  -> execute 切到 G
  -> G 阻塞 / 让出 / syscall / 结束
  -> 回到 schedule

源码入口
#

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 work

GOMAXPROCS 控制的是 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 等待复用
  -> schedule

work 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.go
  • internal/poll/fd_poll_runtime.go
  • runtime/netpoll.go
  • runtime/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 已 ready

netpollblock 大概是:

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.Read

channel 阻塞又是什么样
#

源码位置: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 相关结构,findRunnablestealWork 找不到普通 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
  -> wakep

GMP 的重点不是“G 跑在 M 上”这么一句话。

重点是:G 是可以被保存、挂起、恢复的执行现场;M 是真正跑代码的线程;P 是执行 Go 代码必须拿到的调度资源。runtime 做的事,就是让 G 在 runnable、running、waiting、syscall 之间变化,同时让 M 和 P 尽量不闲着。

Sianao
Author
Sianao
a backend developer

Related


Python