导语
本文基于go1.16.7的源码,详细介绍MPG调度模型的实现。
1、程序启动引导
1.1 程序入口地址
我们写一个最简单的示例程序main.go来看看程序的入口:
package main import “fmt” func main() { fmt.Println(“jemuel”) }
在Linux机器上编译:go build -o main main.go。编译后会生成可执行文件main,该文件为ELF格式。
可以通过以下方式查看程序入口地址(Entry point address):
1、通过readelf
执行readelf -h main,得到如下结果:
可以看到Entry point address为:0x465740,其中0x400000是ELF装载到内存的起始地址,0x65740为入口函数在ELF中的偏移量。
2、通过objdump
执行objdump -f main,得到如下结果:
3、通过gdb
执行gdb main、info files,得到如下结果:
1.2 程序启动流程
知道了程序的入口地址,可以通过gdb跟踪程序的启动引导流程。
gdb指令如下:
>gdb main //启动 >b *0x465740 //设置断点 >r //运行 >layout src //显示源码 >n //单步跟踪
gdb单步跟踪:
rt0_linux_amd64.s/_rt0_amd64_linux
asm_amd64.s/rt0_go
从上面我们可以看到启动入口在目录src/runtime中,该目录包含了go的运行时代码,其中包括许多与操作系统、机器架构、内存管理以及任务调度等相关的代码。整个启动调用链如下:
程序启动时的主线程可以看作M0,在M0中进行系统相关初始化、调度器初始化、创建main函数的协程、执行调度任务等。关键流程如下:
其中各部分详细流程会在后面介绍。
2、MPG调度模型
相信你听说过go的MPG调度模型,那MPG到底是什么,它是如何实现的?接下来我们将详细介绍MPG。
- M:Machine,对应内核线程,初始化时设置的最大数量为10000个(sched.maxmcount)。
- P:Processor,逻辑处理器,负责调度协程,M需要绑定P来执行G代码,默认数量为CPU数量,可通过环境变量GOMAXPROCS调整。
- G:Goroutine,协程,一种轻量级用户线程,通过go关键字创建的执行体。
M、P、G之间的大致关系如下:
2.1 M模型
2.1.1 M结构
// runtime/runtime2.go type m struct { g0 *g //提供系统栈空间,主要用于执行调度任务 curg *g //当前运行的G gsignal *g //信号处理G p puintptr //执行代码关联的P(没有执行则为nil) spinning bool //自旋状态,M当前没有任务并且正在查找任务 mstartfn func() //线程启动后执行函数(在mstart中调用) park note //休眠锁 ... }
2.1.2 M运行情况
实际运行过程中,会创建许多物理线程,其中有些物理线程会运行特殊的任务,比如:调度、监控等。如下所示:
M0是程序启动的主线程,每个新创建的M都会绑定一个G0,该G0栈空间用于执行任务调度或系统监控,除了G0外,其他G要想执行任务,需要通过P绑定M。M与P是一对一关系,P与G是一对多关系。
2.1.3 唤醒M
当创建协程时,会尝试唤醒M执行任务,如果没有空闲M,则会新建M,唤醒流程如下:
函数调用链:
- startm:获取M、P,准备执行任务。
- pidleget:从全局空闲P队列中获取P。
- mget:从全局空闲M队列中获取M。
- newm:创建新M。
- notewakeup:唤醒M。
- allocm:分配M实例,并初始化。
- newm1:创建新M。
- mcommoninit:初始化M部分属性。
- malg:创建G,此处为M的G0。
- newosproc:根据不同OS创建物理线程,线程启动函数为mstart。
2.1.4 启动函数
从上面流程可以看到创建物理线程时传入的启动函数为mstart,查看mstart的源码可以看到最关键的步骤是启动调度,流程如下:
流程中mstartfn是创建M时传入的函数,会根据不同情况传入不同参数,例如:sysmon(监控函数,后面会介绍)、mspinning(设置M自旋)等。
2.2 P模型
2.2.1 P结构
// runtime/runtime2.go type p struct { id int32 //P id status uint32 //P状态 m muintptr //关联的M(idle时为nil) runqhead uint32 //可运行G队列的起始游标,获取G runqtail uint32 //可运行G队列的终止游标,增加G runq [256]guintptr //可运行G队列 runnext guintptr //下一个可运行G gFree struct { gList //空闲G队列 n int32 //空闲G数量 } ... }
2.2.2 创建P
要运行G的任务,需要绑定到一个P,P的数量决定了并行任务的数量,P的数量默认设置为机器处理器的数量,可以通过环境变量GOMAXPROCS进行调整。创建P的函数入口为procresize,该函数的调用有两个途径:程序启动引导阶段在schedinit中调用、GC后在startTheWorld中调用。执行流程如下:
函数调用链如下:
- acquirep:绑定当前M和指定P。
- pidleput:将指定P加入调度器的空闲P队列sched.pidle中。
2.3 G模型
2.3.1 G结构
// runtime/runtime2.go type g struct { stack stack //执行栈空间,[stack.lo, stack.hi) sched gobuf //用于保存执行现场 goid int64 //协程id gopc uintptr //调用者PC/IP startpc uintptr //任务函数 m *m //当前M preempt bool //抢占信号 ... }
2.3.2 创建G
G为函数执行的载体,提供了运行时栈空间,是任务调度的单元,可以理解为一种轻量级的用户线程。当我们在代码中通过go关键字修饰函数调用时,编译器会转换为调用runtime.newproc的汇编指令,该函数会使用被调用函数的指针、参数来创建一个G,流程如下:
函数调用链:
- newproc:创建指定函数的新G。
- runqput:将新G放入当前P的可运行G队列。
- runqputslow:将P中本地G队列前一半放入全局G队列,因为全局队列要加锁,所以叫slow。
- gfget:从空闲G队列获取G(包括P本地、全局)。
- malg:实例化G对象,并分配栈空间,G0栈为8K,其他G栈为2k。
- wakep:唤醒一个M执行任务(新唤醒的M、P会查找可运行G,不一定会执行新G)。
2.3.3 空闲G交换
G在执行完任务后,会进入空闲G队列等待下一次复用,空闲G队列包括:P的本地空闲G队列p.gFree、全局空闲G队列sched.gFree。当P的本地空闲G队列数量超过限制时(64),会取出一半放入全局空闲G队列。
P本地空闲G队列的存在主要是在多线程并发场景下减少锁的消耗。获取P本地空闲G队列时,由于是在同一个M中,因此无需加锁。获取全局空闲G队列时,存在多线程竞争访问,因此需要加锁。
空闲G的交换关系如下:
- gfget:从P本地空闲G队列或全局空闲G队列获取空闲G。
- gfpurge:将P本地空闲G队列全部放入全局空闲G队列。
- gfput:将G放入P本地空闲G队列,如果P本地空闲G队列超过数量限制,会放入一半到全局空闲G队列。
2.3.4 可运行G交换
可运行G是所有等待运行的任务,可运行G队列包括:P的本地可运行G队列p.runq、全局可运行G队列sched.runq。P的本地可运行G队列最大数量为256,当超过限制时,会取出一半放入全局可运行G队列。
P本地可运行G队列的存在主要是在多线程并发场景下减少锁的消耗。获取P本地可运行G队列时,由于是在同一个M中,因此无需加锁,提高调度效率。获取全局可运行G队列时,存在多线程竞争访问,因此需要加锁。
可运行G的交换关系如下:
- injectglist:将G列表放入全局可运行G队列或P的本地可运行G队列。
- globrunqget:将全局可运行G队列放入一批到P本地可运行G队列。
- runqput:将G放入P的本地可运行G队列。
- runqputslow:将P的本地可运行G队列放入一半到全局可运行G队列。
- runqputbatch:将G列表放入P的本地可运行G队列,如果满了,则放入全局可运行G队列。
- globrunqputbatch:将G列表放入全局可运行G队列。
- runqget:从P本地可运行G队列获取一个G。
- runqsteal:将P1本地可运行G队列放入一半到P2本地可运行G队列。
- runqgrab:从P的本地可运行G队列获取获取一半G列表。
- globrunqput:将G放入全局可运行G队列尾部。
- globrunqputhead:将G放入全局可运行G队列头部。
3、系统监控
监控任务由runtime.main创建一个单独的线程启动,该线程不需要绑定P,执行函数为sysmon,该函数循环执行任务,主要包括:
- 将长时间未处理的netpoll结果添加到任务队列
- 超过2分钟没有GC,强制执行
- 收回因syscall长时间阻塞的P
- 向运行超过10ms的G任务发出抢占调度(go1.14引入抢占信号,之前版本是通过设置栈空间,在函数调用时判断栈溢出来实现抢占,这种方式无法抢占纯逻辑任务)。
执行流程如下:
4、任务调度
任务调度主要负责查找可运行G来执行任务,可运行G的来源主要包括:当前线程关联的P的本地可运行G队列、全局可运行G队列、网络连接中已就绪的可运行G、其他P中偷取的可运行G等。
除监控线程外,每个M都会运行一个调度任务,该调度任务在G0栈空间中运行,G0是在每个M初始化时创建的,栈空间大小默认为8K(其他G栈空间大小默认为2k)。
4.1 调度初始化
在执行调度任务之前需要完成一系列的初始化工作,包括栈空间池、处理器架构属性、创建P数组、绑定M0和P等。初始化由函数schedinit完成,调用关系如下所示:
- stackinit:初始化全局栈空间池。
- mallocinit:初始化内存管理。
- mcommoninit:初始化当前M(此处是M0)相关属性,并加入全局M队列allm中。
- cpuinit:初始化处理器架构相关属性。
- gcinit:初始化GC相关属性。
- procresize:调整P数量。
- acquirep:绑定当前M和指定P,此处是M0和一个P。
- pidleput:将指定P加入调度器的空闲P列表sched.pidle中。
4.2 调度流程
调度任务是由函数schedule完成,其执行流程如下:
调度函数schedule是由线程启动函数mstart触发,调用链如下:
- mstart:线程启动的入口函数,系统创建新线程M时传入的就是该函数。
- minit:初始化信号处理。
- mstartm0:初始化M0的信号处理。
- acquirep:绑定当前M和指定P。
- schedule:调度函数,查找可运行G并运行。
- traceReader:返回负责追踪的G(调用ReadTrace)。
- findRunnableGCWorker:查找可运行GC的G。
- globrunqget:从全局可运行G队列头部获取指定数量的G加入指定P的本地G队列中(schedule中调用globrunqget只是从全局队列获取一个G运行,避免全局队列产生饥饿)。
- runqget:从指定P的本地可运行G队列中获取一个G。
- findrunnable:竭尽所有可能查找可运行G,查不到会进入休眠状态。
- wakep:唤醒一个M执行任务(针对找到了GC和trace的G的情况,唤醒的M、P不是执行找到的G )。
- execute:在当前M中执行找到的G。
- runqput:将G放入当前P的可运行G队列。
- runqputslow:将P中本地G队列前一半放入全局G队列。
- netpoll:从网络连接中获取已就绪的G列表。
- injectglist:将可运行G列表加入运行队列(全局或当前P)。
- runqsteal:从其他P的本地G队列获取一半的G到当前P的本地G队列。
- globrunqputbatch:将可运行G列表加入全局可运行G队列中。
- startm:获取M、P,准备执行任务。
- runqputbatch:将可运行G列表加入指定P的本地G队列中。
- runqgrab:从指定P的本地G队列获取一半G。
- releasep:解绑当前M和P。
- pidleput:将当前P放入全局P队列sched.pidle中。
- stopm:停止当前M,进入休眠。
- mput:将当前M放入全局空闲M队列sched.midle中。
- mPark:当前M进入休眠。
- gogo:汇编跳转到G的函数执行。
4.3 查找任务
在schedule中,最关键的部分是调用findrunnable,该函数主要用于竭尽所能的查找可运行的任务,执行流程如下:
4.4 调度循环
查看schedule的源码可以看到,该函数找到可运行G后调用execute函数,execute又调用汇编实现的gogo函数,该函数从调度运行的G0栈空间切换到找到的G栈空间,并通过JMP指令跳转到G绑定的函数执行,由于JMP并不会像CALL一样将下一条指令地址压栈,因此G函数执行完成后RET指令如何保证返回schedule?要搞清楚这个问题,可以看下创建G的函数newproc1,其中有这样一段代码:
newg.sched.sp = sp newg.stktopsp = sp newg.sched.pc = funcPC(goexit) + sys.PCQuantum newg.sched.g = guintptr(unsafe.Pointer(newg)) gostartcallfn(&newg.sched, fn) newg.gopc = callerpc newg.ancestors = saveAncestors(callergp) newg.startpc = fn.fn
关键部分在于函数gostartcallfn,该函数会将newg.sched.pc的初始值goexit地址压入G的栈顶,G执行完成后RET指令返回的就是goexit地址。查看goexit源码可以发现该函数主要完成如下事情:
- 将运行完的G放入空闲G队列等待复用(P本地队列或全局队列);
- 切换到G0栈空间调用schedule执行调度任务。
goexit调用链如下:
- mcall:切换到G0栈空间,汇编实现。
- dropg:移除M和当前G的关联。
- gfput:将G放入P本地空闲G队列,如果P本地空闲G队列超过数量限制,会放入一半到全局空闲G队列。
- schedule:任务调度。
文章评论