咱们始终提到,每一个线程都有一个线程栈,也称为零碎栈;协程g0就运行在这个栈上,而且协程g0执行的就是调度逻辑schedule。Go语言调度器是如何治理以及调度这些成千上万个协程呢?和操作系统一样,保护着可运行队列和阻塞队列吗,有没有所谓的依照工夫片或者是优先级或者是抢占式调度呢?
调度器schedule
咱们曾经晓得每一个P都有一个协程队列runq,该队列存储的都是处于可运行状态的协程,调度器个别状况下只须要从以后p.runq获取协程即可;另外,Go语言为了防止多个P负载调配不平衡,还有一个全局队列sched.runq,如果以后p.runq队列为空,也会从全局队列sched.runq尝试获取协程;如果还为获取不到可执行协程,甚至会从其余P的队列去偷。
当然,无论是p.runq,还是全局的sched.runq,存储的都是处于可运行状态的协程;那处于阻塞状态的协程呢,这些协程在调度器执行的时候,还处于阻塞状态码?不晓得,所以在获取不到可执行协程时,还会尝试去看一下有没有协程解除阻塞了,如果有则还能够调度执行这些协程。
调度器schedule看着比较简单,获取可运行协程,通过execute调度执行该协程:
func schedule() { // schedtick调度计数器,没调度以此加1 // 调度器周期61次,首先从全局队列获取可运行协程 if gp == nil { if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 { lock(&sched.lock) gp = globrunqget(_g_.m.p.ptr(), 1) unlock(&sched.lock) } } if gp == nil { gp, inheritTime = runqget(_g_.m.p.ptr()) } if gp == nil { gp, inheritTime = findrunnable() // blocks until work is available } //调度执行 execute(gp, inheritTime)}
依照之前咱们说的,先查找以后P的协程队列,再查找全局队列,然而这样可能会导致全局队列的协程长时间得不到调度,所以Go语言调度器每执行61次,都会优先从全局队列获取可运行协程。留神,在查找全局队列的时候,存在多线程并发问题,所以是须要先加锁的。findrunnable是一个比较复杂的函数,看正文"blocks until work is available",获取不到协程时,甚至会block(以后线程M暂停)。execute当然就是切换栈,执行以后协程了。
func execute(gp *g, inheritTime bool) { //设置协程g与M的相互援用关系 _g_ := getg() _g_.m.curg = gp gp.m = _g_.m //协程状态:运行中 casgstatus(gp, _Grunnable, _Grunning) //协程切换 gogo(&gp.sched)}
gogo咱们上一篇文章曾经介绍过了,纯汇编代码写的,实现了栈桢的切换,以及代码的跳转。不晓得你有没有留神到第二个参数inheritTime,这是什么含意呢?示意这次协程执行是否继承上一个协程的工夫片。如果工夫片为10ms,上一个协程曾经执行了5ms,如果继承,则表明这一个协程最多只能执行5ms,工夫片就会完结,从而再次调度其余协程。这么说Go语言调度器是有工夫片的概念了?咱们先保留一个疑难。
Go语言什么时候执行调度schedule呢?程序刚启动必定会执行,而协程因为某些起因阻塞了(chan的读写,socket的读写等等),或者是协程执行完结了,这时候也是须要从新调度其余协程的;协程阻塞通常是通过runtime.gopark函数实现的:
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) { // 切换到零碎栈,执行park_m mcall(park_m)}func park_m(gp *g) { //协程状态:阻塞 casgstatus(gp, _Grunning, _Gwaiting) //从新调度 schedule()}
协程阻塞之后,想复原协程的调度呢?与gopark对应的,runtime.goready函数用于复原协程的调度:
func goready(gp *g, traceskip int) { systemstack(func() { ready(gp, traceskip, true) })}func ready(gp *g, traceskip int, next bool) { //更改状态为可运行;增加到P的协程队列 casgstatus(gp, _Gwaiting, _Grunnable) runqput(_g_.m.p.ptr(), gp, next) wakep() releasem(mp)}
合作式抢占调度
Go语言调度器到底有工夫片的概念吗?其实咱们能够通过一个小程序测试一下:
package mainimport ( "fmt" "runtime")func main() { //设置P的数目为1 runtime.GOMAXPROCS(1) go func() { fmt.Println("hello world") for { //死循环 } }() //main协程被动让出 runtime.Gosched() fmt.Println("main end")}
上一篇文章解说协程创立的时候提到,go关键字创立协程时,只是将该协程增加到以后P的队列,并没有调度执行;所以,为了防止主协程执行打印语句完结后程序退出,咱们能够通过runtime.Gosched函数使得main协程被动让出CPU,这样Go调度器就能先调度执行其余协程了。另外,咱们通过runtime.GOMAXPROCS设置P的数目为1,即最多只能有一个线程M绑定P,即最多只能有一个调度器运行。这样,如果Go语言调度器没有工夫片的概念,则一旦子协程执行到循环,就会始终执行死循环,导致调度器再也没有机会调度其余协程了;最终的景象就是main协程的打印语句无奈执行。
执行后果怎么样呢?如果你是在Go1.18环境运行该程序,你会发现失常输入了"main end";然而如果你是在Go1.13版本及以下运行该程序,你将发现程序始终执行,没有输入"main end"。你能够下载两个版本的Go试一试,看看后果是不是这样的。Go1.13版本及以下不会输入,是否阐明Go1.13版本及以下,没有工夫片的概念?其实也不然。你能够再试试上面这个程序:
package mainimport ( "fmt" "runtime")func main() { runtime.GOMAXPROCS(1) go func() { fmt.Println("hello world") var arr []int for i := 0; i < 100; i ++ { arr = append(arr, i) } for { test(arr) } }() runtime.Gosched() fmt.Println("main end")}func test(arr []int) []int { diff := make([]int, len(arr), len(arr)) diff[0] = arr[0] for i := 1; i < len(arr); i ++ { diff[i] = arr[i] - arr[i - 1] } return diff}
这一次咱们的死循环不是简略的空语句,而是函数调用,而且test函数也有一些略微简单的语句。Go1.13版本及以下再执行这个程序试试呢?你会发现,神奇的是,主协程又输入了"main end"。为什么呢?惟一不同的是第一个程序的死循环只是简略的空语句,第二个程序的循环是函数调用!
怎么,函数调用就非凡?是的,函数调用就是不同于一般语句,就是非凡。Go语言在编译函数的时候,还增加了一些本人的代码。还记得上一篇文章,在介绍协程栈溢出时候提到,Go语言编译阶段,在所有用户函数,都加了一点代码逻辑,判断栈顶指针SP小于某个地位时,阐明栈空间有余,须要扩容了。须要扩容的时候,执行的是函数runtime.morestack_noctxt,而该函数(其实是runtime.newstack)不仅仅是判断是否须要扩容,还会判断以后协程是否应该让出CPU。
留神,Go语言并没有严格限度协程执行工夫片,而是通过一种合作式抢占调度(1.13版本及以下)的形式,实现伪工夫片性能。这就须要一个帮手了,Go程序启动时不止创立一般的调度线程,还存在辅助线程,辅助线程的主函数是runtime.sysmon,每10ms轮询一次,检测是否有协程执行工夫过长,如果有,则告诉该协程让出CPU。
//创立新线程,主函数sysmonnewm(sysmon, nil)func sysmon() { delay = 10 * 1000 // up to 10ms usleep(delay) for { //preempt long running G's retake(nanotime()) }}
preempt的意思是抢占。咱们先思考两个问题:
1)sysmon线程如何判断哪些协程执行工夫过长?遍历协程吗?必定不是这样。想想线程M调度协程流程,要求必须先绑定P,而且M正在调度执行的协程只有一个,所以呢?只须要遍历P,通过p.m.curg就能获取到正在执行的协程。接下来就是检测协程执行工夫了,每个协程记录调度工夫吗?貌似也行,Go语言为每一个P保护了p.schedtick,M每调度一次协程,该值加1,而且还有一个变量p.schedwhen记录了上次调度的工夫。这就好办了,每10毫秒检测的时候,如果p.schedtick没法产生扭转,阐明这10ms内没有产生调度,则应该告诉以后协程p.m.curg让出CPU了。
2)sysmon线程如何跨线程告诉该协程让出CPU呢?还记得协程栈扩容是怎么判断的吗?stackguard0!告诉协程让出CPU也是通过在协程栈stackguard0地位设置非凡标识实现的。
仿佛整个流程通顺了,第一步,Go语言编译阶段在test函数增加一些本人的代码,如下:
"".test STEXT 0x0000 00000 (test.go:26) CMPQ SP, 16(R14) 0x0004 00004 (test.go:26) PCDATA $0, $-2 0x0004 00004 (test.go:26) JLS 404 0x0194 00404 (test.go:26) MOVQ AX, 8(SP) 0x0199 00409 (test.go:26) MOVQ BX, 16(SP) 0x019e 00414 (test.go:26) MOVQ CX, 24(SP) 0x01a3 00419 (test.go:26) CALL runtime.morestack_noctxt(SB) 0x01b7 00439 (test.go:26) JMP 0
R14寄存器就是以后协程g,想想构造体g的第一个字段stack占16字节,第二个字段就是stackguard0,所以这里比拟栈顶SP寄存器与16(R14)地址大小。那明确了,stackguard0地位处设置的非凡标识必定是一个十分大的值,任何栈地位都小于该值。
第二步,sysmon线程10ms周期执行retake函数抢占长时间执行的G:
func retake(now int64) uint32 { //遍历所有的P for i := 0; i < len(allp); i++ { _p_ := allp[i] s := _p_.status if s == _Prunning { t := int64(_p_.schedtick) //不等于,阐明在这10ms期间从新调度协程了; if int64(pd.schedtick) != t { pd.schedtick = uint32(t) pd.schedwhen = now continue } //G长时间运行 if pd.schedwhen+forcePreemptNS > now { continue } preemptone(_p_) } }}func preemptone(_p_ *p) bool { mp := _p_.m.ptr() gp := mp.curg if gp == nil || gp == mp.g0 { return false } //抢占标识 gp.preempt = true gp.stackguard0 = stackPreempt return true}const forcePreemptNS = 10 * 1000 * 1000 // 10msstackPreempt = (1<<(8*sys.PtrSize) - 1) & -1314 //十分大
个别状况下,每一个P都有可能有正在执行的协程p.m.curg,所以这里须要遍历所有的P,如果P的状态为_Prunning,阐明该P曾经被M绑定且正在调度协程。p.schedtick每调度一次协程值加1,所以检测时如果这个值与上次记录不一样,则阐明这10ms期间必定从新调度协程了,跳过即可。否则,如果距上次调度工夫曾经过来很久了,则通过preemptone抢占,看吧,抢占标识就是通过设置gp.stackguard0实现的。
第三步,子协程执行10ms之后,进入到函数test,检测stackguard0标识,发现栈顶指针SP小于stackguard0,这时候跳转到了runtime.morestack_noctxt函数。函数morestack_noctxt也是汇编写的,一系列判断之后,最终调用了函数runtime.newstack,就是在这里,判断是否被抢占了,如果是,则让出CPU。
func newstack() { gp := getg().m.curg preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt if preempt { gopreempt_m(gp) // never return;抢占 }}func gopreempt_m(gp *g) { //批改协程状态 casgstatus(gp, _Grunning, _Grunnable) //增加到全局队列 lock(&sched.lock) globrunqput(gp) unlock(&sched.lock) //从新调度 schedule()}
newstack就是通过gp.stackguard0判断是否被抢占了。另外,咱们发现协程被抢占时,被增加到了全局队列,这样相当于优先级升高了。
这就是Go1.13版本及以下实现的合作式抢占调度,协程只有在进入函数时,才有可能检测是否被抢占了,所以死循环中只是简略的语句是无奈抢占的。而且函数如果非常简单,还有可能被优化掉,所以你测试的时候,可能发现循环中调用了函数,然而运行后果却显示无奈被抢占。
基于信号的抢占式调度
Go1.13版本及以下是基于合作式的抢占调度,所以死循环中是简略的语句,还是简单的函数调用,最终后果是不一样的。那Go1.14版本以上呢?如同无论哪一种状况,都能被抢占,是做了哪些优化吗?是的,Go1.14版本以上实现的是基于信号的抢占。
信号?kill -signal pid发送的就是信号,比方咱们罕用SIGTERM信号终止过程,而Linux总共有64种信号可供选择。当然,程序想要接管并解决某种信号,还须要设置信号处理器:
struct sigaction{ void (*sa_handler)(int); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); }
sa_hander就是咱们的信号处理器函数指针。Go语言设置的信号处理函数为runtime.sighandler。
上面看一下Go1.18实现的抢占逻辑,同样是函数preemptone:
func preemptone(_p_ *p) bool { //合作式抢占标记 gp.stackguard0 = stackPreempt //如果反对信号抢占,发送信号 if preemptMSupported { preemptM(mp) } return true}func preemptM(mp *m) { pthread_kill(pthread(mp.procid), sigPreempt)}const sigPreempt = _SIGURG
函数pthread_kill可用于向指定线程发送信号,抉择第几种信号也是有要求的,有很多信号有非凡含意,是不能轻易应用的,Go语言选择的协程抢占信号是SIGURG。sysmon线程发送抢占信号,调度线程M就会收到信号,判断收到的是抢占信号,则换出以后协程,从新调度。
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) { //抢占信号 if sig == sigPreempt { doSigPreempt(gp, c) }}
最终其实和合作式抢占一样,都是将以后协程增加到全局队列,触发调度,这里就不在赘述。
总结
本篇文章次要介绍了Go语言调度器,调度算法由runtime.schedule函数实现,程因为某些起因阻塞了(chan的读写,socket的读写等等),或者是协程执行完结了,都会触发从新调度。另外Go语言还反对抢占调度,辅助协程sysmon检测长时间执行协程,设置抢占标识或者发送抢占信号。Go1.13版本及以下实现的是合作式调度,一般死循环语句没有方法被抢占,只有执行函数调用时(Go编译阶段增加了一些代码),才有可能实现抢占,而Go1.14版本及以上,通过信号实现的抢占调度则没有这个问题。