如何理解Go语言基于信号的抢占式调度
本篇内容主要讲解“如何理解Go语言基于信号的抢占式调度”,感兴趣的朋友不妨来看看。本文介绍的方法操作简单快捷,实用性强。下面就让小编来带大家学习“如何理解Go语言基于信号的抢占式调度”吧!
扎赉诺尔网站制作公司哪家好,找创新互联公司!从网页设计、网站建设、微信开发、APP开发、自适应网站建设等网站项目制作,到程序开发,运营维护。创新互联公司2013年至今到现在10年的时间,我们拥有了丰富的建站经验和运维经验,来保证我们的工作的顺利进行。专注于网站建设就选创新互联公司。
识别事故的本质,并且用一个非常简单的示例展示出来,是功力的一种体现。那次事故的原因可以简化成如下的 demo:
demo-1
我来简单解释一下上面这个程序。在主 goroutine 里,先用 GoMAXPROCS 函数拿到 CPU 的逻辑核心数 threads。这意味着 Go 进程会创建 threads 个数的 P。接着,启动了 threads 个数的 goroutine,每个 goroutine 都在执行一个无限循环,并且这个无限循环只是简单地执行 x++。
接着,主 goroutine sleep 了 1 秒钟;最后,打印 x 的值。
你可以自己思考一下,输出会是什么?
如果你想出了答案,接着再看下面这个 demo:
demo-2
我也来解释一下,在主 goroutine 里,只启动了一个 goroutine(虽然程序里用了一个 for 循环,但其实只循环了一次,完全是为了和前面的 demo 看起来更协调一些),同样执行了一个 x++ 的无限 for 循环。
和前一个 demo 的不同点在于,在主 goroutine 里,我们手动执行了一次 GC;最后,打印 x 的值。
如果你能答对第一题,大概率也能答对第二题。
下面我就来揭晓答案。
其实我留了一个坑,我没说用哪个版本的 Go 来运行代码。所以,正确的答案是:
Go 版本 | demo-1 | demo-2 |
---|---|---|
1.13 | 卡死 | 卡死 |
1.14 | 0 | 0 |
这个其实就是 Go 调度器的坑了。
假设在 demo-1 中,共有 4 个 P,于是创建了 4 个 goroutine。当主 goroutine 执行 sleep 的时候,刚刚创建的 4 个 goroutine 马上就把 4 个 P 霸占了,执行死循环,而且竟然没有进行函数调用,就只有一个简单的赋值语句。Go 1.13 对这种情况是无能为力的,没有任何办法让这些 goroutine 停下来,进程对外表现出“死机”。
demo-1 示意图
由于 Go 1.14 实现了基于信号的抢占式调度,这些执行无限循环的 goroutine 会被调度器“拿下”,P 就会空出来。所以当主 goroutine sleep 时间到了之后,马上就能获得 P,并得以打印出 x 的值。至于 x 为什么输出的是 0,不太好解释,因为这是一种未定义(有数据竞争,正常情况下要加锁)的行为,可能的一个原因是 CPU 的 cache 没有来得及更新,不过不太好验证。
理解了这个 demo,第二个 demo 其实是类似的道理:
demo-2 示意图
当主 goroutine 主动触发 GC 时,需要把所有当前正在运行的 goroutine 停止下来,即 stw(stop the world),但是 goroutine 正在执行无限循环,没法让它停下来。当然,Go 1.14 还是可以抢占掉这个 goroutine,从而打印出 x 的值,也是 0。
Go 1.14 之前的版本,能否抢占一个正在执行死循环的 goroutine 其实是有讲究的:
能否被抢占,不是看有没有调用函数,而是看函数的序言部分有没有插入扩栈检测指令。
如果没有调用函数,肯定不会被抢占。
有些虽然也调用了函数,但其实不会插入检测指令,这个时候也不会被抢占。
像前面的两个 demo,不可能有机会在函数扩栈检测期间主动放弃 CPU 使用权,从而完成抢占,因为没有函数调用。具体的过程后面有机会再写一篇文章详细讲,本文主要看基于信号的抢占式调度如何实现。
preemptone
一方面,Go 进程在启动的时候,会开启一个后台线程 sysmon,监控执行时间过长的 goroutine,进而发出抢占。另一方面,GC 执行 stw 时,会让所有的 goroutine 都停止,其实就是抢占。这两者都会调用 preemptone() 函数。
preemptone() 函数会沿着下面这条路径:
preemptone->preemptM->signalM->tgkill
向正在运行的 goroutine 所绑定的的那个 M(也可以说是线程)发出 SIGURG 信号。
注册 sighandler
每个 M 在初始化的时候都会设置信号处理函数:
initsig->setsig->sighandler
信号执行过程
我们从“宏观”层面看一下信号的执行过程:
信号执行过程
主程序(线程)正在“勤勤恳恳”地执行指令:它已经执行完了指令 m,接着就要执行指令 m+1 了……不幸在这个时候发生了,线程收到了一个信号,对应图中的 ①。
接着,内核会接管执行流,转而去执行预先设置好的信号处理器程序,对应到 Go 里,就是执行 sighandler,对应图中的 ② 和 ③。
最后,执行流又交到线程手上,继续执行指令 m+1,对应图中的 ④。
这里其实涉及到了一些现场的保护和恢复,内核都帮我们搞定了,我们不用操心。
dosigPreempt
当线程收到 SIGURG 信号的时候,就会去执行 sighandler 函数,核心是 doSigPreempt 函数。
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) { ... if sig == sigPreempt && debug.asyncpreemptoff == 0 { doSigPreempt(gp, c) } ... }
doSigPreempt 这个函数其实很短,一会儿就执行完了。
func doSigPreempt(gp *g, ctxt *sigctxt) { ... if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok { // Adjust the PC and inject a call to asyncPreempt. ctxt.pushCall(funcPC(asyncPreempt), newpc) } ... }
isAsyncSafePoint 函数会返回当前 goroutine 能否被抢占,以及从哪条指令开始抢占,返回的 newpc 表示安全的抢占地址。
接着,pushCall 调整了一下 SP,设置了几个寄存器的值就返回了。按理说,返回之后,就会接着执行指令 m+1 了,但那还怎么实现抢占呢?其实魔法都在 pushCall 这个函数里。
pushCall
在分析这个函数之前,我们需要先复习一下 Go 函数的调用规约,重点回顾一下 CALL 和 RET 指令就行了。
call 和 ret 指令
call 指令可以简单地理解为 push ip + JMP。这个 ip 其实就是返回地址,也就是调用完子函数接下来该执行啥指令的地址。所以 push ip 就是在 call 一个子函数之前,将返回地址压入栈中,然后 JMP 到子函数的地址执行。
ret 指令和 call 指令刚好相反,它将返回地址从栈上 pop 到 IP 寄存器,使得 CPU 从这个地址继续执行。
理解了 call 和 ret,我们再来分析 pushCall 函数:
func (c *sigctxt) pushCall(targetPC, resumePC uintptr) { // Make it look like we called target at resumePC. sp := uintptr(c.rsp()) sp -= sys.PtrSize *(*uintptr)(unsafe.Pointer(sp)) = resumePC c.set_rsp(uint64(sp)) c.set_rip(uint64(targetPC)) }
注意看这行注释:
// Make it look like we called target at resumePC.
它清晰地说明了这个函数的作用:让 CPU 误以为是 resumePC 调用了 targetPC。而这个 resumePC 就是上一步调用 isAsyncSafePoint 函数返回的 newpc,它代表我们抢占 goroutine 的指令地址。
前两行代码将 SP 下移了 8 个字节,并且把 resumePC 入栈(注意,它其实是一个返回地址),接着把 targetPC 设置到 ip 寄存器,sp 设置到 SP 寄存器。这使得从内核返回到用户态执行时,不是从指令 m+1,而是直接从 targetPC 开始执行,等到 targetPC 执行完,才会返回到 resumePC 继续执行。整个过程就像是 resumePC 调用了 targetPC 一样。而 targetPC 其实就是 funcPC(asyncPreempt),也就是抢占函数。
于是我们可以看到,信号处理器程序 sighandler 只是将一个异步抢占函数给“安插”进来了,而真正的抢占过程则是在 asyncPreempt 函数中完成。
异步抢占
当执行完 sighandler,执行流再次回到线程。由于 sighandler 插入了一个 asyncPreempt 的函数调用,所以 goroutine 原本的任务就得不到推进,转而执行 asyncPreempt 去了:
asyncPreempt 调用链路
mcall(fn) 的作用是切到 g0 栈去执行函数 fn, fn 永不返回。在 mcall(gopreempt_m) 这里,fn 就是 gopreempt_m。
gopreempt_m 直接调用 goschedImpl:
goschedImpl
dropg
最精彩的部分就在 goschedImpl 函数。它首先将 goroutine 的状态从 running 改成 runnable;接着调 dropg 将 g 和 m 解绑;然后调用 globrunqput 将 goroutine 丢到全局可运行队列,由于是全局可运行队列,所以需要加锁。最后,调用 schedule() 函数进入调度循环。关于调度循环,可以看这篇文章。
运行 schedule 函数用的是 g0 栈,它会去寻找其他可运行的 goroutine,包括从当前 P 本地可运行队列获取、从全局可运行队列获取、从其他 P 偷等方式找到下一个可运行的 goroutine 并执行。
至此,这个线程就转而去执行其他的 goroutine,当前的 goroutine 也就被抢占了。
那被抢占的这个 goroutine 什么时候会再次得到执行呢?
因为它已经被丢到全局可运行队列了,所以它的优先级就会降低,得到调度的机会也就降低,但总还是有机会再次执行的,并且它会从调用 mcall 的下一条指令接着执行。
还记得 mcall 函数的作用吗?它会切到 g0 栈执行 gopreempt_m,自然它也会保存 goroutine 的执行进度,其实就是 SP、BP、PC 寄存器的值,当 goroutine 再次被调度执行时,就会从原来的执行流断点处继续执行下去。
到此,相信大家对“如何理解Go语言基于信号的抢占式调度”有了更深的了解,不妨来实际操作一番吧!这里是创新互联网站,更多相关内容可以进入相关频道进行查询,关注我们,继续学习!
网页名称:如何理解Go语言基于信号的抢占式调度
当前路径:http://scyanting.com/article/geppig.html