了解go在协程调度上的改进

2022-01-23 21:53:01 浏览数 (1)

大家好,我是秋名山小白,秋名山半山腰上的补给员,欢迎各位老司机来歇歇脚!

网上聊GMP模型的文章有很多,相信大家对其都有一定的了解。本篇文章在大家了解GMP模型的基础上,再深入分享下Go协程的调度策略和其演变过程。我猜本文需要阅读大概15min。

1. 常见的调度策略

调度的策略有很多种,下面列举两个常见的调度策略:

策略一:协作式调度

协作式调度是指以多个任务之间以协作的方式切换执行,每个任务执行一会,任务执行到某个点时会自己让出当前资源交给其他正在等待的任务,这显得比较主动和自愿。

下面是一张描述多个任务进行协作式调度的图:

协作式调度

如上图,任务在执行一段时间后会主动调用yield 方法让出当前执行资源,yield 方法会触发调度程序执行,调度程序会从等待运行的队列中选取优先级高的任务来执行。

优点

•实现简单。

•任务可以自己控制放弃资源的时间。

•由于任务保持自己的生命周期,调度程序不必注意每个任务的状态。

缺点

•执行时间不可控,如果遇到流氓任务一直不放弃资源会导致其他任务得不到资源被饿死。

•新任务执行实时性差

策略二:抢占式调度

抢占式调度是指依靠外部抢占的方式去中断当前任务,让其放弃当前资源交给其他正在等待的任务,这显得比较被动和强迫。

下面是一张描述多个任务进行抢占式调度的图:

抢占式调度

如上图,任务在执行过程中,收到了外部发来的interrupt中断,这时候任务会停止执行,然后切换到调度程序,调度程序会从等待运行的队列中选取优先级高的任务来执行。因为刚才的任务并没有执行完成,所以它仍然有再次被调度到的机会,一直到该任务执行完成。

优点

•任务执行时间可控。

•不会出现不合理分配的情况,保证公平性。

缺点

•实现复杂

•抢占操作会导致一些额外开销。

•由于无法感知任务的执行情况,对于单个任务的执行效率无法做到最优。•调度程序需要保存任务的上下文。


协作式 VS 抢占式

协作式vs抢占式

如上图,我们可以很直观的看到如下信息:

1.由于抢占式调度的次数大于协作式,导致同样的任务执行过程中抢占式的整体执行时间大于协作式。

2.由于抢占式调度可以抢占超时执行的任务,所以对于红颜色这种需要执行时间很短的任务在抢占式调度下会较早的完成任务执行。

当然上图也是很片面的展示,具体怎么选择调度策略还需要看具体的使用场景和任务性质。

因为多数情况下任务执行的时间都不确定,在协作式调度中一旦任务没有主动让出资源,那么就会导致它任务等待和阻塞,但是完全依赖于抢占式调度会导致任务执行时间比较短的任务阻塞时造成资源浪费,所以调度系统一般都会以抢占式的任务调度为主,同时支持任务的协作式调度

2. Golang调度实现

golang调度的基本单元是协程,协程是比线程更加轻量的执行单元,它是由go runtime在用户态实现,它比线程依赖于系统调度显得更加轻量。

这里就不展开描述GMP模型了,不了解的小伙伴可以去看下刘丹冰的GMP模型相关文章:https://zhuanlan.zhihu.com/p/323271088

golang的调度实现经历很多次的更新迭代,不断的完善。截止go1.13版本,golang的调度实现已经兼备协作式调度和抢占式调度,但是这个抢占式调度是基于协作的抢占式调度。在go1.14版本后开始引入基于信号的抢占式调度

下面一起看看吧。

协作式调度

go早期只实现了协作式调度,那它是怎么协作的呢?需要业务代码主动去调用调度程序吗?

实现原理

正如上文描述,协作式调度是需要当前任务主动的调用调度程序。

在golang里就是协程主动调用runtime包下的调度逻辑,调度逻辑会做出调度。

下面是一张描述多个协程进行协作调度的图:

go的协作式调度

上图中当一个协程G在执行一段时间后主动调用Gosched/Goexit方法去执行调度逻辑。

不同的调度方法执行的调度逻辑不同,我们需要根据实际需要调度对应的调度方法。在日常编码中,我们一般不会主动调用这类调度方法,我们一般会间接调用到这些方法。

触发时机

go中间接调用调度方法的地方有很多,比如遇到阻塞或者GC时都会主动去调用这两个方法,把CPU交给其他协程。

比如需要读一个文件,我们一般会调用 file.Read(buf) 方法。这个方法内部会先去读一次当前文件,如果当前文件是非阻塞的,则会立马返回结果。当返回是不可读时,go会把当前文件描述符放进netpoll去等待可读事件,然后调用runtime.gopark() 把执行权给别的协程。 下文我们会说到调用了runtime.gopark() 是不会把当前协程放回待运行队列的,那这个协程要啥时候才能被调度到呢?那就需要等待netpoll感知到之前注册的文件描述符可读时,再去唤醒之前的协程,把它重新放回队列等待调度。

常见的调度方法

runtime.Gosched() 执行该方法会让当前协程放弃执行,将其放入等待队列,调度其他协程来执行。

代码语言:javascript复制
func goschedImpl(gp *g) {
    //....
    dropg()
    //....
    globrunqput(gp)
    //...
    schedule()
}

runtime.gopark() 执行该方法会让当前协程放弃执行,然后调度其他协程来执行。

代码语言:javascript复制
func park_m(gp *g) {
    //...
    dropg()
    //...
    schedule()
}

runtime.Goexit() 执行该方法会让当前协程放弃执行,然后调度其他协程来执行。

代码语言:javascript复制
func goexit0(gp *g) {
    //...
    dropg()
    //...
    gfput(_g_.m.p.ptr(), gp) //把G放入gfree队列
    //...
    schedule()
}
三者区别

runtime.Gosched() : 会将之前的G放回待运行队列,之前的G在后面会被调度到。

runtime.gopark() :不会将之前的G放回待运行队列,之前的G需要等待其他G恢复才能执行。

runtime.Goexit() :不会将之前的G放回待运行队列,之前的G会被回收。

基于协作的抢占式调度

go在1.2版本开始引入基于协作的抢占式调度,它的引入解决了协作式调度的两个比较明显的问题:

•某些协程执行时间过长,导致其他协程得不到调度,任务执行时延高。

•垃圾回收的时候需要STW,需要让所有执行的协程暂停工作,但是协作式调度需要等待G主动让出CPU的时候才能执行到调度器,而且还需要等待所有的G都停止工作,其时间可想而知,极端情况下是十分漫长的。

实现原理

看到这里可能有小伙伴比较迷惑,为啥都抢占了还叫协作呀。

如果不知道原理的话,确实听令人迷惑的。

其实抢占只是表示它是从外部发起的抢占调度,不是自发进行调度的。基于协作式的抢占不像线程抢占一样直接抢占触发中断,它是需要当前执行G配合的。

基于协作的抢占是通过给G设置标志位(stackguard0)实现的。当G在函数调用的时候会检查这个标志位,当其为StackPreempt 时,那就说明当前G被别的G抢占,就主动去执行调度代码。

下面是一张描述多个协程调度过程中,G3协程被监控线程(sysmon)检测到超时运行后基于协作的抢占调度的图:

基于协作的抢占式调度

1 sysmon 检测到超时运行协程发生抢占

这个动作可以看上图的(C -> E):

•C: 检查超时运行的协程

•D: 发现G3运行时间大于10ms

•E: 调用 preemptone 方法设置G3协程的抢占标志位(stackguard0)StackPreempt, 在sysmon设置标志位的时候,G3协程仍然正常执行

preemptone方法源码如下:

代码语言:javascript复制
func preemptone(_p_ *p) bool {
    //...
    //如上文说到的标志位stackguard0
    gp.stackguard0 = stackPreempt
    return true
}
2 G3进行函数调用时,触发抢占检查

这个动作可以看上图的(9 -> 9.1):

•9 -> 9.1: 当G3进行函数调用的时候,会调用runtime.morestack方法,其内部会去检查抢占标志位(stackguard0)的值。如果stackguard0的值为StackPreempt时,则表明当前G已经被其他G抢占,需要主动调用调度方法gopreempt_m进行调度,gopreempt_m方法的作用和上文提到的Gosched方法作用一样,这样就完成了协程抢占。

runtime.newstack方法检查标志位代码如下:

代码语言:javascript复制
// Called from runtime.morestack when more stack is needed.
//调用函数时会触发,是被编译器插进来的
 func newstack() {
    //....
    //获取标志位
    preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt
    //...
    if preempt {
        //判断当前M是否是安全可抢占
        if !canPreemptM(thisg.m) {
            //暂时不能抢占,所以就不退出了,但是标志已经设置,等下次再来试试
            gp.stackguard0 = gp.stack.lo   _StackGuard
            gogo(&gp.sched) // never return
        }
    }
    //...
    if preempt {
        //...
        // Act like goroutine called runtime.Gosched. 
        //看上一行注释,懂了吧
        gopreempt_m(gp) // never return
    }

    //...
    //没有抢占继续执行
    gogo(&gp.sched)
}

触发时机

下面我们来了解下go什么时候会去抢占协程,一般有两个地方会调用这个方法去抢占协程:

1.垃圾回收的时候暂停程序。

2.监控线程( sysmon )在 G 超时运行(>10ms)的时候抢占。

如果当前G是因为系统调用导致的超时运行是无法被抢占的,这时会解绑当前M和P的关系,让P重新找个M绑定。


基于信号的抢占式调度

go在1.2版本开始引入基于协作的抢占式调度,在大多数情况下,这足以让Go开发人员忽略抢占细节,专注于编写清晰的并行代码,但它有尖锐的边缘,我们已经看到它一次又一次地降低了开发人员的体验。当它出错时,它就会出错,导致神秘的全系统延迟问题,有时甚至完全冻结。而且由于这是Go语言语义之外存在的语言实现问题,因此这些失败令人惊讶,并且非常难以调试。 --- Austin Clements

上面的一段话取自go非协作式G抢占的提案里,链接点击原文查看。

确实,虽然基于协作的抢占式调度解决了一部分问题,但是它还是不够完备。

在一些极端情况下,还是会出现比较严重的问题,比如协程长时间执行并且不会执行到抢占标志检查就不会触发调度。

Clements 提案中表示建议取消抢占检查,他认为这种方法将解决延迟抢占的问题,并且运行时开销为零。

最终Clements在go1.14中实现了基于信号的抢占式调度

实现原理

基于信号的抢占式调度是非协作式抢占调度。

如上文所示,纯抢占式实现是很复杂的,这里我们简单了解下原理。

下面是一张描述多个协程调度过程中,G3协程被监控线程(sysmon)检测到超时运行后发生基于信号抢占调度的图:

基于信号的抢占式调度

上图描述的抢占过程整体和基于协作的抢占差不多,不同的地方在于抢占的方式。

1 注册信号

基于信号的抢占调度第一步肯定是要注册信号的处理事件,这个过程在上图没有展示,因为注册信号这个动作是M0线程做的,对应信号的处理事件是全局共享的。

•信号:SIGURG(即上文提到的sigPreempt)

•回调函数:func doSigPreempt(gp g, ctxt sigctxt)

2 触发抢占,发送信号给要被抢占G的M

这个动作可以看上图的(C -> F):

•C: 检查超时运行的协程

•D: 发现G3运行时间大于10ms

•E,F: 给G3所在线程发送抢占信号

发送信号核心代码如下:

代码语言:javascript复制
func signalM(mp *m, sig int) {
    //发送信号的系统调用
    pthread_kill(pthread(mp.procid), uint32(sig))
}
3 G3所在CPU执行注册的软中断事件

这个动作可以看上图的(9->11):

•9: 开始处理中断

•10: 修改寄存器植入指令

•11: 中断结束,返回G3线程用户态

中断处理逻辑:

代码语言:javascript复制
// doSigPreempt handles a preemption signal on gp.
func doSigPreempt(gp *g, ctxt *sigctxt) {
    // Check if this G wants to be preempted and is safe to
    // preempt.
    if wantAsyncPreempt(gp) {
        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)
        }
    }
    //...
}
4 中断返回后执行植入的调度逻辑

这个动作可以看上图的(12->13):

•12: 执行 asyncPreempt2

•13: 执行调度逻辑

中断返回后执行的用户态逻辑如下:

代码语言:javascript复制
//go:nosplit
func asyncPreempt2() {
    gp := getg()
    gp.asyncSafePoint = true
    if gp.preemptStop {
        //类似 gopark
        mcall(preemptPark)
    } else {
        //类似 Gosched
        mcall(gopreempt_m)
    }
    gp.asyncSafePoint = false
}

触发时机

和协作式抢占差不多

一般有两个地方会调用这个方法去抢占协程:

1.垃圾回收的时候暂停程序。

2.监控线程( sysmon )在 G 超时运行(>10ms)的时候抢占。

相关问题

信号选择依据

1.我们需要处理多个平台上的不同信号;2.该信号可以随意出现并且不触发任何后果;3.该信号不会被内部的 libc 库使用并拦截;4.该信号需要被调试器透传;

这个信号是SIGURG,所以小伙伴们码代码的时候就不要使用这个信号啦,这个信号被内部用了。

3. 总结

本文简单介绍了Go发展到1.15的调度策略演变过程,希望对大家有帮助。如果有什么不一样的看法,欢迎评论留言。

个人微信

最后,感谢大家的观看,我会时不时给大家分享一些有用的干货,让各位老司机在飙车的过程中得到补给,需要补给请认准秋名山补给站!

4. 参考资料

•[system-design-scheduler]:https://draveness.me/system-design-scheduler

•[Proposal: Non-cooperative goroutine preemption] :https://github.com/golang/proposal/blob/master/design/24543-non-cooperative-preemption.md

•[GMP] : https://zhuanlan.zhihu.com/p/323271088

0 人点赞