当前位置: 首页 > 科技观察

Go语言中基于信号的抢占式调度深度解密

时间:2023-03-14 11:08:23 科技观察

本文转载请联系码农桃花源公众号。不知道大家在实际工作中有没有遇到老版本Go调度器的坑:死循环导致程序“崩溃”。去年有过,P0事故,写了一篇很蠢的找bug的文章。认清事故的本质,并用一个非常简单的例子来说明,是一种技巧的体现。那个事故的起因可以简化成下面的demo:demo-1我简单解释一下上面的程序。在maingoroutine中,首先使用GoMAXPROCS函数获取CPU的逻辑核心线程数。这意味着Go进程将创建P个线程。接下来启动threads个goroutine,每个goroutine都在执行一个死循环,这个死循环就是执行x++。接下来,主协程休眠1秒;最后,打印x的值。你可以自己想想,输出会是什么?如果你想通了,那么看下面的demo:demo-2我也会说明在maingoroutine中,只启动了一个goroutine(虽然程序使用了一个for循环,但实际上只循环了一次,它完全是为了和之前的demo看起来更协调),还执行了一个x++的无限for循环。与之前的demo不同的是,在maingoroutine中,我们手动进行了一次GC;最后,打印x的值。如果第一道题你能答对,那么第二道题你也能答对的概率很高。下面我来揭晓答案。其实留个坑,我没说用哪个版本的Go跑代码。因此,正确答案是:Goversiondemo-1demo-21.13StuckStuck1.1400这其实就是Go调度器的坑。假设在demo-1中,一共有4个P,所以创建了4个goroutine。当maingoroutine执行sleep时,刚刚创建的4个goroutine立即占用4个P,执行死循环,并没有函数调用,只有简单的赋值语句。Go1.13对这种情况束手无策,没有办法停止这些goroutines,进程对外会显得“死了”。demo-1示意图由于Go1.14实现了基于信号的抢占式调度,这些执行无限循环的goroutine将被调度器“拿下”,P将被释放。所以当maingoroutinesleep时间到了,可以马上获取P,打印出x的值。至于为什么x的输出是0,不好解释,因为这是一个未定义的行为(有数据竞争,一般情况下需要加锁)。一种可能的原因是CPU缓存还没有来得及更新,但还不太好验证。理解了这个demo,第二个demo其实也差不多:demo-2示意图当主goroutine主动触发GC时,需要停止所有当前正在运行的goroutine,也就是stw(stoptheworld),但是goroutine是无限执行的循环,不能让它停止。当然,Go1.14还是可以抢占这个goroutine,打印出x的值,也是0。在Go1.14之前的版本中,一个正在执行死循环的goroutine是否可以被抢占,其实是有讲究的:是否可以preempted不依赖于函数是否被调用,而是依赖于函数的序言中是否插入了栈扩展检测指令。如果函数没有被调用,肯定不会被抢占。有些函数虽然也调用了,但是不会插入检测指令,此时也不会被抢占。和前面两个demo一样,在检测函数栈扩展的时候不可能有机会主动放弃CPU使用权,从而完成抢占,因为没有函数调用。具体过程以后有机会会写文章详细讲解。本文主要看如何实现基于信号的抢占式调度。Preemptone一方面,当Go进程启动时,会启动一个后台线程sysmon来监控执行时间过长的goroutine,然后发出抢占。另一方面,GC在执行stw的时候,会停止所有的goroutine,其实就是抢占。这两个都将调用preemptone()函数。preemptone()函数将遵循以下路径:preemptone->preemptM->signalM->tgkill向绑定到正在运行的goroutine的M(或线程)发送SIGURG信号。注册sighandler每个M在初始化时都会设置信号处理函数:initsig->setsig->sighandler信号执行过程我们从“宏观”层面来看信号执行过程:信号执行过程的主程序(线程)是“diligent”“真诚地”执行指令:它已经执行了指令m,接下来会执行指令m+1……不幸的是,就在这个时候,线程收到了一个信号,对应图中的①。然后,内核会接管执行流程,转而去执行预先设定好的signalhandler程序,对应Go,也就是执行sighandler,对应图中的②和③。最后将执行流程交给线程,继续执行指令m+1,对应图中④。这实际上涉及到一些现场保护和恢复。内核已经帮我们搞定了,所以我们不用担心。dosigPreempt当线程收到SIGURG信号后,会执行sighandler函数,其核心是doSigPreempt函数。funcsighandler(siguint32,info*siginfo,ctxtunsafe.Pointer,gp*g){...ifsig==sigPreempt&&debug.asyncpreemptoff==0{doSigPreempt(gp,c)}...}doSigPreempt这个函数其实很短,只是一会儿就搞定了。funcdoSigPreempt(gp*g,ctxt*sigctxt){...ifok,newpc:=isAsyncSafePoint(gp,ctxt.sigpc(),ctxt.sigsp(),ctxt.siglr());ok{//调整PC并注入调用asyncPreempt.ctxt。pushCall(funcPC(asyncPreempt),newpc)}...}isAsyncSafePoint函数会返回当前goroutine是否可以被抢占,以及从哪条指令开始抢占。返回的newpc代表安全抢占地址。然后,pushCall调整了SP,设置了几个寄存器的值并返回。按理说返回后,m+1指令会被执行,但是怎么实现抢占呢?事实上,神奇之处在于pushCall函数。pushCall在分析这个函数之前,我们需要回顾一下Go函数的调用协议,重点关注CALL和RET指令。call和ret指令call指令可以简单理解为puship+JMP。这个ip其实就是返回地址,也就是调用子函数后接下来应该执行什么指令的地址。所以puship就是在调用子函数之前将返回地址压栈,然后JMP到子函数的地址执行。ret指令与call指令正好相反。它将返回地址从堆栈弹出到IP寄存器,允许CPU从该地址继续执行。了解了call和ret之后,我们来分析一下pushCall函数:func(c*sigctxt)pushCall(targetPC,resumePCuintptr){//MakeitlooklikewecalldetargetatresumePC.sp:=uintptr(c.rsp())sp-=sys.PtrSize*(*uintptr)(unsafe.Pointer(sp))=resumePCc.set_rsp(uint64(sp))c.set_rip(uint64(targetPC))}注意这行注释://MakeitlooklikewecalldetargetatresumePC。很清楚的解释了这个函数的作用:让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:goschedImpldropg最好的部分是goschedImpl函数。它首先将goroutine的状态从running变为runnable;然后调用dropg解除g和m的绑定;然后调用globrunqput将goroutine扔到全局可运行队列中,需要加锁因为是全局可运行队列。最后调用schedule()函数进入调度循环。关于调度循环,可以看这篇文章。g0栈是用来运行schedule函数的,它会寻找其他可运行的goroutine,包括从当前P本地runnable队列中获取,从全局runnable队列中获取,从其他P中窃取等方式寻找下一个可运行goroutine并实施。此时,该线程转而执行其他goroutine,当前goroutine被抢占。被抢占的goroutine什么时候会再次执行?因为已经被扔进了全局runnable队列,所以它的优先级会降低,被调度的机会也会减少,但总有机会再次执行,从下一条调用mcall的指令开始执行。还记得mcall函数的作用吗?它会切换到g0栈执行gopreempt_m,自然也会保存goroutine的执行进度,其实就是SP、BP、PC寄存器的值。当goroutine再次被调度执行时,会从原来的Executioncontinue到执行流程断点处。总结本文描述了Go语言中基于信号的异步抢占的整个过程。一起回顾一下:M注册了一个SIGURG信号处理函数:sighandler。当sysmon线程检测到执行时间过长的goroutine或GCstw时,会向对应的M(或线程,每个线程对应一个M)发送SIGURG信号。内核收到信号后,执行sighandler函数,通过pushCall插入asyncPreempt函数调用。回到当前goroutine执行asyncPreempt函数,通过mcall切换到g0栈执行gopreempt_m。将当前goroutine插入全局runnable队列,M继续寻找其他goroutine运行。当被抢占的goroutine被调度再次执行时,它会继续原来的执行流程。