GMP调度原理

1. 核心概念

1.1 线程

通常语义中的线程,指的是内核级线程,核心点如下:

(1)是操作系统最小调度单元;

(2)创建、销毁、调度交由内核完成;

(3)可充分利用多核,实现并行.

1.2 协程

协程,又称为用户级线程,核心点如下:

(1)与线程存在映射关系,为 M:1;

(2)创建、销毁、调度在用户态完成,对内核透明,所以更轻;

(3)从属同一个内核级线程,无法并行;一个协程阻塞会导致从属同一线程的所有协程无法执行。

1.3 goroutine

Goroutine,经 Golang 优化后的特殊“协程”,核心点如下:

(1)与线程存在映射关系,为 M:N,由调度器来实现p和m的动态绑定;

(2)创建、销毁、调度在用户态完成,对内核透明,足够轻便;

(3)可利用多个线程,实现并行;

(4)栈空间大小可动态扩缩,因地制宜,默认为2KB

2. gmp模型

gmp = goroutine + machine + processor ,下面先单独拆出每个组件进行介绍,最后再总览全局,对 gmp 进行总述。

2.1 g

(1)g 即goroutine,是 golang 中对协程的抽象;

(2)g 有自己的运行栈、状态、以及执行的任务函数(用户通过 go func 指定);

(3)g 需要绑定到 p 才能执行,在 g 的视角中,p 就是它的 cpu.

2.2 p

(1)p 即 processor,是 golang 中的调度器;

(2)p 是 gmp 的中枢,借由 p 承上启下,实现 g 和 m 之间的动态有机结合;

(3)对 g 而言,p 是其 cpu,g 只有被 p 调度,才得以执行;

(4)对 m 而言,p 是其执行代理,为其提供必要信息的同时(可执行的 g、内存分配情况等),并隐藏了繁杂的调度细节;

(5)p 的数量决定了 g 最大并行数量,可由用户通过 GOMAXPROCS 进行设定(超过 CPU 核数时无意义)。

2.3 m

(1)m 即 machine,是 golang 中对线程的抽象;

(2)m 不直接执行 g,而是先和 p 绑定,由其实现代理;

(3)借由 p 的存在,m 无需和 g 绑死,也无需记录 g 的状态信息,因此 g 在全生命周期中可以实现跨 m 执行。

2.4 gmp

GMP 宏观模型如上图所示,下面对其要点和细节进行逐一介绍:

(1)M 是线程的抽象;G 是 goroutine;P 是承上启下的调度器;

(2)M调度G前,需要和P绑定;

(3)全局有多个M和多个P,但同时并行的G的最大数量等于P的数量;

(4)G的存放队列有三类:P的本地队列;全局队列;和wait队列(图中未展示,为io阻塞就绪态goroutine队列,比如执行了channel操作而阻塞);

(5)M调度G时,优先取P本地队列,其次取全局队列,最后取wait队列;这样的好处是,取本地队列时,可以接近于无锁化,减少全局锁竞争;

(6)为防止不同P的闲忙差异过大,设立work-stealing机制,本地队列为空的P可以尝试从其他P本地队列偷取一半的G补充到自身队列。

3. 核心数据结构

3.1 g

type g struct {
    // ...
    m         *m      
    // ...
    sched     gobuf
    // ...
}


type gobuf struct {
    sp   uintptr
    pc   uintptr
    ret  uintptr
    bp   uintptr // for framepointer-enabled architectures
}

(1)m:在 p 的代理下,负责执行当前 g 的 m;

(2)sched.sp:保存 CPU 的 rsp 寄存器的值,指向函数调用栈栈顶;

(3)sched.pc:保存 CPU 的 rip 寄存器的值,指向程序下一条执行指令的地址;

(4)sched.ret:保存系统调用的返回值;

(5)sched.bp:保存 CPU 的 rbp 寄存器的值,存储函数栈帧的起始位置。

其中 g 的生命周期由以下几种状态组成:

const(
  _Gidle = itoa // 0
  _Grunnable // 1
  _Grunning // 2
  _Gsyscall // 3
  _Gwaiting // 4
  _Gdead // 6
  _Gcopystack // 8
  _Gpreempted // 9
)

(1)_Gidle 值为 0,为协程开始创建时的状态,此时尚未初始化完成;

(2)_Grunnable 值 为 1,协程在待执行队列中,等待被执行,可以有很多个;

(3)_Grunning 值为 2,协程正在执行,同一时刻一个 p 中只有一个 g 处于此状态;

(4)_Gsyscall 值为 3,协程正在执行系统调用,比如创建、读写文件、执行系统命令;

(5)_Gwaiting 值为 4,协程处于挂起态,需要等待被唤醒. gc、channel 通信或者锁操作时经常会进入这种状 态;

(6)_Gdead 值为 6,协程刚初始化完成或者已经被销毁,会处于此状态;

(7)_Gcopystack 值为 8,协程正在栈扩容流程中;

(8)_Greempted 值为 9,协程被抢占后的状态.

3.2 m

type m struct {
    g0      *g     // goroutine with scheduling stack
    // ...
    tls           [tlsSlots]uintptr // thread-local storage (for x86 extern register)
    // ...
}

(1)g0:一类特殊的调度协程,不用于执行用户函数,负责执行 g 之间的切换调度. 与 m 的关系为 1:1;

(2)tls:thread-local storage,线程本地存储,存储内容只对当前线程可见. 线程本地存储的是 m.tls 的地址,m.tls[0] 存储的是当前运行的 g,因此线程可以通过 g 找到当前的 m、p、g0 等信息.

3.3 p

type p struct {
    // ...
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr
    
    runnext guintptr
    // ...
}

(1)runq:本地 goroutine 队列,最大长度为 256.

(2)runqhead:队列头部;

(3)runqtail:队列尾部;

(4)runnext:下一个可执行的 goroutine.

3.4 schedt

type schedt struct {
    // ...
    lock mutex
    // ...
    runq     gQueue
    runqsize int32
    // ...
}

sched 是全局 goroutine 队列的封装:

(1)lock:一把操作全局队列时使用的锁;

(2)runq:全局 goroutine 队列;

(3)runqsize:全局 goroutine 队列的容量。

4. 调度流程

4.1 两种g的转换

如 3.2 小节中所谈及的,goroutine 的类型可分为两类:

I 负责调度普通 g 的 g0,执行固定的调度流程,与 m 的关系为一对一;

II 负责执行用户函数的普通 g.

m 通过 p 调度执行的 goroutine 永远在普通 g 和 g0 之间进行切换,当 g0 找到可执行的 g 时,会调用 gogo 方法,调度 g 执行用户定义的任务;当 g 需要主动让渡或被动调度时,会触发 mcall 方法,将执行权重新交还给 g0.

gogo 和 mcall 可以理解为对偶关系。

4.2 调度场景

共有以下四种调度场景:

这种广义“调度”可分为几种类型:

4.2.1 主动让出执行权

一种用户主动执行让渡的方式,主要方式是,用户在执行代码中调用了 runtime.Gosched 方法,此时当前 g 会当让出执行权,主动进行队列等待下次被调度执行.

代码位于 runtime/proc.go

func Gosched() {
    checkTimeouts()
    mcall(gosched_m)
}

4.2.2 goroutine被阻塞

常见的触发方式为因 channel 操作或互斥锁操作陷入阻塞等操作,底层会走进 gopark 方法.

代码位于 runtime/proc.go

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
    // ...
    mcall(park_m)
}

goready 方法通常与 gopark 方法成对出现,能够将 g 从阻塞态中恢复,重新进入等待执行的状态.

代码位于 runtime/proc.go

func goready(gp *g, traceskip int) {
    systemstack(func() {
        ready(gp, traceskip, true)
    })
}

4.2.3 正常退出

g 中的执行任务已完成,g0 会将当前 g 置为死亡状态,发起新一轮调度.

4.2.4 抢占式调度

倘若 g 执行系统调用超过指定的时长,且全局的 p 资源比较紧缺,此时将 p 和 g 解绑,抢占出来用于其他 g 的调度. 等 g 完成系统调用后,会重新进入可执行队列中等待被调度。

值得一提的是,前 3 种调度方式都由 m 下的 g0 完成,唯独抢占调度不同.

因为发起系统调用时需要打破用户态的边界进入内核态,此时 m 也会因系统调用而陷入僵持,而内核态的系统调用状态无法同步到内核态的g0,所以发起系统调用的goroutine一直无法被休眠等待,而g0也无法开始新一轮的调度。

为了避免这种情况发生,在 Golang 进程会有一个全局监控协程 monitor g 的存在,这个 g 会越过 p 直接与一个 m 进行绑定,不断轮询对所有 p 的执行状况进行监控. 倘若发现满足抢占调度的条件,则会从第三方的角度出手干预,主动发起该动作.

和上面三种调度方式不同,抢占调度的执行者不是 g0,而是一个全局的 monitor g,代码位于 runtime/proc.go 的 retake 方法中:

// src/runtime/proc.go

func retake(now int64) uint32 {
    n := 0
    
    lock(&allpLock)
    for i := 0; i < len(allp); i++ {
        _p_ := allp[i]
        if _p_ == nil {
            // This can happen if procresize has grown
            // allp but not yet created new Ps.
            continue
        }
        pd := &_p_.sysmontick
        // ...
        if s == _Psyscall {            
            // ...
            if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
                continue
            }
            unlock(&allpLock)
            if atomic.Cas(&_p_.status, s, _Pidle) {
                n++
                _p_.syscalltick++
                handoffp(_p_)
            }
            incidlelocked(1)
            lock(&allpLock)
        }
    }
    unlock(&allpLock)
    return uint32(n)
}

执行流程如下:

(1)加锁后,遍历全局的 p 队列,寻找需要被抢占的目标:

(2)倘若某个 p满足下述任意一个条件,则会进行抢占调度:

1. 本地P队列不会空
2. 全局没有空闲的m和p
3. 本地P执行的系统调度时间大于10ms

(3)抢占调度的步骤是,先将当前 p 的状态更新为 idle,然后步入 handoffp 方法中,为 p 寻找接管的 m(因为其原本绑定的 m 正在执行系统调用)。

4.3 宏观调度流程

集齐各部分理论碎片之后,我们可以尝试对 gmp 的宏观调度流程进行整体串联:

(1)以 g0 -> g -> g0 的一轮循环为例进行串联;

(2)g0 执行 schedule() 函数,寻找到用于执行的 g(runable);

(3)g0 执行 execute() 方法,更新当前 g、p 的状态信息,并调用 gogo() 方法,将执行权交给 g;

(4)g 因主动让渡( gosche_m() )、被动调度( park_m() )、正常结束( goexit0() )等原因,调用 m_call 函数,执行权重新回到 g0 手中;

(5)g0 执行 schedule() 函数,开启新一轮循环.

4.4 schedule

调度流程的主干方法是位于 runtime/proc.go 中的 schedule 函数,此时的执行权位于 g0 手中:

func schedule() {
    // ...
    gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available


    // ...
    execute(gp, inheritTime)
}

(1)寻找到下一个可执行的 goroutine;

(2)执行该 goroutine.

4.5 findRunnable

简单概括为先从本地的P中寻找,再从全局队列寻找,再从wait队列中找,最后从其他P中偷过来

4.6 execute

当 g0 为 m 寻找到可执行的 g 之后,接下来就开始执行 g. 这部分内容位于 runtime/proc.go 的 execute 方法中:

func execute(gp *g, inheritTime bool) {
    _g_ := getg()


    _g_.m.curg = gp
    gp.m = _g_.m
    casgstatus(gp, _Grunnable, _Grunning)
    gp.waitsince = 0
    gp.preempt = false
    gp.stackguard0 = gp.stack.lo + _StackGuard
    if !inheritTime {
        _g_.m.p.ptr().schedtick++
    }


    gogo(&gp.sched)

(1)更新 g 的状态信息,有可执行状态变为运行中状态,并建立 g 与 m 之间的绑定关系;

(2)更新 p 的总调度次数;

(3)调用 gogo 方法,执行 goroutine 中的任务.

4.7 gosched_m

g 执行runtime.Gosched()来主动让出时,会调用 mcall 方法将执行权归还给 g0,并由 g0 调用 gosched_m 方法,位于 runtime/proc.go 文件中:

func Gosched() {
    // ...
    mcall(gosched_m)
}
func gosched_m(gp *g) {
    goschedImpl(gp)
}


func goschedImpl(gp *g) {
    status := readgstatus(gp)
    if status&^_Gscan != _Grunning {
        dumpgstatus(gp)
        throw("bad g status")
    }
    casgstatus(gp, _Grunning, _Grunnable) 
    dropg()
    lock(&sched.lock)
    globrunqput(gp)
    unlock(&sched.lock)


    schedule()

(1)将当前 g 的状态由执行中切换为待执行 _Grunnable:

(2)调用 dropg() 方法,将当前的 m 和 g 解绑;

func dropg() {
    _g_ := getg()


    setMNoWB(&_g_.m.curg.m, nil)
    setGNoWB(&_g_.m.curg, nil)
}

(3)将 g 添加到全局队列当中:

    lock(&sched.lock)
    globrunqput(gp)
    unlock(&sched.lock

(4)开启新一轮的调度:

    schedule()

4.8 park_m 与 ready

g 在阻塞时上层应用(channel、mutex)会调用 mcall 方法切换至 g0,并调用 park_m 方法将 g 置为阻塞态,执行流程位于 runtime/proc.go 的 gopark 方法当中:

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
    // ...
    mcall(park_m)
}
func park_m(gp *g) {
    _g_ := getg()


    casgstatus(gp, _Grunning, _Gwaiting)
    dropg()


    // ...
    schedule()

(1)将当前 g 的状态由 running 改为 waiting;

(2)将 g 与 m 解绑;

(3)执行新一轮的调度 schedule.

上层应用(channel、mutex)在某一时刻可能会触发goroutine的唤醒,比如读channel的goroutine会唤醒写channel的goroutine。上层应用会执行 goready 方法将 处于wating的g 重新置为可执行的状态,然后会将其添加到唤醒者的 p 的本地队列中,方法位于 runtime/proc.go .

func goready(gp *g, traceskip int) {
    systemstack(func() {
        ready(gp, traceskip, true)
    })
}
func ready(gp *g, traceskip int, next bool) {
    // ...
    _g_ := getg()
    // ...
    casgstatus(gp, _Gwaiting, _Grunnable)
    runqput(_g_.m.p.ptr(), gp, next)
    // ...
}

(1)先将 g 的状态从阻塞态改为可执行的状态;

(2)调用 runqput 将当前 g 添加到唤醒者 p 的本地队列中,如果队列满了,会连带 g 一起将一半的元素转移到全局队列.

4.9 goexit0

当 g 执行完成时,会先执行 mcall 方法切换至 g0,然后调用 goexit0 方法,内容为 runtime/proc.go:

// Finishes execution of the current goroutine.
func goexit1() {
    // ...
    mcall(goexit0)
}
func goexit0(gp *g) {
    _g_ := getg()
    _p_ := _g_.m.p.ptr()


    casgstatus(gp, _Grunning, _Gdead)
    // ...
    gp.m = nil
    // ...


    dropg()


    // ...
    schedule()

(1)将 g 状态置为 dead;

(2)解绑 g 和 m;

(3)开启新一轮的调度.

4.10 retake

与 4.7-4.9 小节的区别在于,抢占调度的执行者不是 g0,而是一个全局的 monitor g,代码位于 runtime/proc.go 的 retake 方法中:

func retake(now int64) uint32 {
    n := 0
    
    lock(&allpLock)
    for i := 0; i < len(allp); i++ {
        _p_ := allp[i]
        if _p_ == nil {
            // This can happen if procresize has grown
            // allp but not yet created new Ps.
            continue
        }
        pd := &_p_.sysmontick
        // ...
        if s == _Psyscall {            
            // ...
            if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
                continue
            }
            unlock(&allpLock)
            if atomic.Cas(&_p_.status, s, _Pidle) {
                n++
                _p_.syscalltick++
                handoffp(_p_)
            }
            incidlelocked(1)
            lock(&allpLock)
        }
    }
    unlock(&allpLock)
    return uint32(n)
}

(1)加锁后,遍历全局的 p 队列,寻找需要被抢占的目标,倘若某个 p 同时满足下述条件,则会进行抢占调度:

I 执行系统调用超过 10 ms;

II p 本地队列有等待执行的 g;

III 或者当前没有空闲的 p 和 m.

(2)抢占调度的步骤是,先将当前 p 的状态更新为 idle,解除 p 和 当前 m 之间的绑定,因为 m 即将进入系统调用而导致短暂不可用,然后步入 handoffp 方法中,判断是否需要为 p 寻找接管的 m(因为其原本绑定的 m 正在执行系统调用)

(3)当以下四个条件满足其一时,则需要为 p 获取新的 m:

I 当前 p 本地队列还有待执行的 g;

II 或全局繁忙(没有空闲的 p 和 m,全局 g 队列为空)

III 或需要处理网络 socket 读写请求

(4)获取 m 时,会先尝试获取已有的空闲的 m,若不存在,则会创建一个新的 m.

posted @   独揽风月  阅读(801)  评论(0编辑  收藏  举报
(评论功能已被禁用)
编辑推荐:
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· .NET Core 中如何实现缓存的预热?
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 如何调用 DeepSeek 的自然语言处理 API 接口并集成到在线客服系统
· 【译】Visual Studio 中新的强大生产力特性
点击右上角即可分享
微信分享提示