GO基础学习(11)GMP模型-循环调度
写在开头
非原创,知识搬运工/整合工,仅用于自己学习,实验环境为阿里云Centos7,go1.20
GMP往期回顾
知识点前瞻
![](https://img.haomeiwen.com/i20602965/eed71d860721be84.png)
带着问题去阅读
- 简述如何循环调度
- m的创建时机?
- 循环调度有发生g0栈的切换吗
- 普通g和g0的退出有什么区别
- 调度时机(被动和主动)
- 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行,这里就不贴出,说一下大致逻辑:
- 为了保证公平,当全局队列中有等待执行的G时,保证有一定几率会从GRQ中获取G
- 从P的LRQ中获取G (先查找p.runnext,这个可以看作队首)
- 两者都没有就进行阻塞查找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结构体中
![](https://img.haomeiwen.com/i20602965/720964c7c1842e6f.png)
这也是我们把GMP的调度叫做循环调度的原因,最后又绕回schedule
小结下g0栈的切换:
- 刚开始创造g0,保存g0的调度信息
- 执行main G(newproc1生成newg)的时候,g0栈->newg栈
- m跳转到newg.sched.pc地址执行main代码
-
循环调度中不断会发生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)
![](https://img.haomeiwen.com/i20602965/37973e6f72473c28.png)
使用exit0发送信号量退出整个进程,简单粗暴,这也意味着,主程序执行完了,就不会去管协程的死活了,所以就引入了后面的waitgroup等待协程执行
小结下main G和普通g newg的退出:
- main G是直接exit退出整个进程
- 普通g会先让m切换回g0的栈,再清理参数,放入对象池,最后重新发起新一轮调度
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队首),还有就是主动挂起和系统调用
- 主动挂起,gopark函数,被暂停任务不会放回队列(被调走直到挂起任务结束),gopark会切换到g0栈上执行parkm函数
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,等待调度
- 系统调用触发调度,Go 语言通过
syscall.Syscall
和syscall.RawSyscall
等使用汇编语言编写的方法封装操作系统提供的所有系统调用,汇编太复杂先搁置一下
3.1抢占式调度
还有一种情况能触发调度就是抢占式调度,当一个g运行太长时间(10ms以上),会导致其他g难以获得机会运行,为了让其他g有机会运行,会进行抢占,是否进行抢占依赖于sysmon协程(这个就是runtime.main通过newm创建的协程)。
sysmon协程不依赖于p,是一个后台监控协程,抢占开始的时机需要满足以下几个规则:
- 系统调用超过10ms则进行抢占
- p的LRQ有等待运行的g
- 没有空闲的p
总结
schedule() -> execute() -> gogo() -> goroutine 任务 -> goexit() -> goexit1() -> mcall() -> goexit0() -> schedule()![](https://img.haomeiwen.com/i20602965/cd2410becaaad902.png)