GO基础学习(11)GMP模型-循环调度

2023-04-29  本文已影响0人  温岭夹糕

写在开头

非原创,知识搬运工/整合工,仅用于自己学习,实验环境为阿里云Centos7,go1.20

GMP往期回顾

  1. 程序到进程
  2. 进程到线程再到协程
  3. GO调度器
  4. GMP介绍
  5. 调度器初始化

知识点前瞻

image.png

带着问题去阅读

  1. 简述如何循环调度
  2. m的创建时机?
  3. 循环调度有发生g0栈的切换吗
  4. 普通g和g0的退出有什么区别
  5. 调度时机(被动和主动)
  6. sysmon有什么特别的地方

1.schedule

书接上文newproc执行用户程序main函数,发生了g0->newg栈的切换,之后就是循环调度代码mstart
mstart调用mstart0,mstart0又调用mstart1,mstart1的核心是schdule函数
schedule

func mstart0() {
    gp := getg()
    ...
    mstart1()
    ...
}

func mstart1() {
    schedule()
}

这里m0从newg的栈切回g0的栈,所以gp=g0,schedule函数永不返回

func schedule() {
    mp := getg().m

top:
    pp := mp.p.ptr()
    pp.preempt = false
    gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available

从g0上获取m0的p,再调用findRunable从p中获取G,之前介绍过很多次获取G的机制包括偷取,因为findrunable代码将近500行,这里就不贴出,说一下大致逻辑:

  1. 为了保证公平,当全局队列中有等待执行的G时,保证有一定几率会从GRQ中获取G
  2. 从P的LRQ中获取G (先查找p.runnext,这个可以看作队首)
  3. 两者都没有就进行阻塞查找G,包括再从全局和本地队列找一次,从netpoll获取G,从其他P偷取G

回到schedule,当g获取到,做好准备工作就开始下一步

execute(gp, inheritTime)

execute负责执行G
通过runtime.gogo函数将G调度到m上执行,
gogo函数是汇编代码,截取几行核心片段
1.将G放入到m.tls中,运行这条代码之前也就是初始化m0.tls存的是g0,现在这里要变成新获取的G,又一次g0->newg栈的切换

MOVQ    gobuf_g(BX), DX
get_tls(CX)
MOVQ    DX, g(CX)

g0栈的切换就是m绑定的g的切换
当G执行完代码后会使用JMP指令跳转到runtime.goexit位置执行函数(还记得吗goexit被压入栈顶,然后伪造sp)

JMP  BX 

TEXT runtime·goexit(SB),NOSPLIT,$0-0
    CALL    runtime·goexit1(SB)

func goexit1() {
    mcall(goexit0)
}

goexit上一章的newg.sched.pc赋值时出现过一次,这次是执行goexit,本质是调用mcall,让m执行goexit0函数,注意此时从newg栈上退出是g0栈

func goexit0(gp *g) {
    gp.m = nil
    ...
    gp.param = nil
    gp.labels = nil
    gp.timer = nil

    dropg()
    gfput(_g_.m.p.ptr(), gp)
    schedule()
}

goexit0在完成参数复原的工作后(因为newg是在堆上申请的,为防止内存泄露,用完后要么被销毁,要么被复用),又一次让m加入schedule的调度,实现了调度的循环。drogp是解绑g和m的关系,gfput是把g放入空闲队列中(对象池),以供下次使用,这个对象池在scheduler结构体中


image.png

这也是我们把GMP的调度叫做循环调度的原因,最后又绕回schedule

小结下g0栈的切换:

  1. 刚开始创造g0,保存g0的调度信息
  2. 执行main G(newproc1生成newg)的时候,g0栈->newg栈
  3. m跳转到newg.sched.pc地址执行main代码
  4. 循环调度中不断会发生g0->newg栈,newg->g0栈的切换 image.png

2. G的声明周期

schedule在获取新g后通过gogo让m实现栈的切换来执行newg,现在我们回头看看用户的main函数如何执行,即rutime.newproc的参数,runtime.main,来学习G的一生(执行到退出,中年到入土)
获取m0,但是这里的g是newg栈

mp:=getg().m

创建监控,该线程独立于调度器,不跟p挂钩

    if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
        systemstack(func() {
            newm(sysmon, nil, -1)
        })
    }

调用runtime初始化函数

doInit(&runtime_inittask)

开启垃圾回收

gcenable()

调用main.main函数,这才是我们用户程序员写的代码

fn := main_main
fn()

系统调用,退出进程

exit(0)
image.png

使用exit0发送信号量退出整个进程,简单粗暴,这也意味着,主程序执行完了,就不会去管协程的死活了,所以就引入了后面的waitgroup等待协程执行

小结下main G和普通g newg的退出:

2.1 newm

runtime.main中用systemstack执行newm函数,这个函数是调用系统调用用来创建m的,newm是对newm1的包装

func newm(fn func()){
mp := allocm(pp, fn, id)
newm1(mp)
}

func newm1(mp *m) {
    if iscgo {
        ...
    }
    newosproc(mp)
}

newm的allocm实现m的创建和对应g0的设置,但是这个m是m数据结构,并不是操作系统真正的线程,因此调用newm1的newosproc,利用底层调用系统调用的clone来创建系统线程,使用系统调用clone创建的线程会主动调用exit,或传入函数runtime.mstart(汇编代码)返回主动退出,我们看到newm的参数有一个fn匿名函数,mstart调用newm会传入销毁线程的函数或加入对象池的函数,这里让线程也有一个完整的声明周期
需要注意的是runtime.main创建的线程是用来后台监控如垃圾回收和调度抢占的,并不是执行G的,还记得allm线程对象池吗?GO语言在运行时会通过runtime.startm来启动P,如果P没有从allm中获取M,就会调用newm创建新线程,wakep函数唤醒线程也会调用startm来调用newm

3.触发调度

我们之前实验分析过一种主动触发调度的方式就是创建协程(加入LRQ队首),还有就是主动挂起和系统调用

func gopark(){
 mcall(park_m)
}

func park_m(gp *g) {
    mp := getg().m
    casgstatus(gp, _Grunning, _Gwaiting)
    dropg()
    schedule()
}

dropg又见面了,解绑g和m的关系,schedule触发新一轮调度,那被挂起的什么时候能被调用呢?
等待条件满足后使用goready来唤醒

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

重新加入LRQ,等待调度

3.1抢占式调度

还有一种情况能触发调度就是抢占式调度,当一个g运行太长时间(10ms以上),会导致其他g难以获得机会运行,为了让其他g有机会运行,会进行抢占,是否进行抢占依赖于sysmon协程(这个就是runtime.main通过newm创建的协程)。
sysmon协程不依赖于p,是一个后台监控协程,抢占开始的时机需要满足以下几个规则:

  1. 系统调用超过10ms则进行抢占
  2. p的LRQ有等待运行的g
  3. 没有空闲的p

总结

schedule() -> execute() -> gogo() -> goroutine 任务 -> goexit() -> goexit1() -> mcall() -> goexit0() -> schedule() image.png
上一篇 下一篇

猜你喜欢

热点阅读