聊聊Goroutine调度策略~

原文地址:聊聊Goroutine调度策略~

在《聊聊Goroutine-线程模型与调度器(一)》一文中提到过,goroutine的调度实质上就是程序代码按照一定算法在一定时间挑选出合适的goroutine并放到CPU上运行的过程,这句话搞定了调度系统的三大核心问题:

  1. 调度时机:什么时候会发生调度?

  2. 调度策略:使用什么策略挑选下一个进入CPU运行的goroutine?

  3. 切换机制:如何将挑选出来的goroutine放到CPU上运行?

本文主要聊的就是调度策略。

来看runtime/proc.go文件2467行代码来分析schedule函数:

// One round of scheduler: find a runnable goroutine and execute it.// Never returns.func schedule() {
   
       _g_ := getg()   //_g_ = m.g0    ......    var gp *g    ......       if gp == nil {
   
       // Check the global runnable queue once in a while to ensure fairness.    // Otherwise two goroutines can completely occupy the local runqueue    // by constantly respawning each other.       //为了保证调度的公平性,每个工作线程每进行61次调度就需要优先从全局运行队列中获取goroutine出来运行,       //因为如果只调度本地运行队列中的goroutine,则全局运行队列中的goroutine有可能得不到运行        if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
   
               lock(&sched.lock) //所有工作线程都能访问全局运行队列,所以需要加锁            gp = globrunqget(_g_.m.p.ptr(), 1) //从全局运行队列中获取1个goroutine            unlock(&sched.lock)        }    }    if gp == nil {
   
           //从与m关联的p的本地运行队列中获取goroutine        gp, inheritTime = runqget(_g_.m.p.ptr())        if gp != nil && _g_.m.spinning {
   
               throw("schedule: spinning with local work")        }    }    if gp == nil {
   
           //如果从本地运行队列和全局运行队列都没有找到需要运行的goroutine,        //则调用findrunnable函数从其它工作线程的运行队列中偷取,如果偷取不到,则当前工作线程进入睡眠,        //直到获取到需要运行的goroutine之后findrunnable函数才会返回。        gp, inheritTime = findrunnable() // blocks until work is available    }    ......    //当前运行的是runtime的代码,函数调用栈使用的是g0的栈空间    //调用execte切换到gp的代码和栈空间去运行    execute(gp, inheritTime)  }

schedule分三步分别从各运行队列中寻找可运行的goroutine,如下:

  1. 从全局运行队列中寻找goroutine,为保证调度公平性,各工作线程每经过61次调度就需优先从全局运行队列中找出一个goroutine运行,如此才能保证全局运行队列中goroutine都能得到调度的机会,全局运行队列是所有工作线程都可以访问的,所以访问它之前需要加锁。

  2. 从工作线程本地运行队列中寻找goroutine,如不需或不能从全局运行队列获取goroutine,则从本地运行队列中获取。

  3. 从其它工作线程运行队列中偷取goroutine,如第二部没有获取到goroutine,则需调用findrunnable从其它工作线程运行队列中偷取goroutine,findrunnable在偷取之前,或再次尝试从全局运行队列和当前工作线程本地运行队列中寻找需要运行的goroutine。

从全局运行队列中寻找可运行的goroutine通过globrunqget完成,该函数第一个参数是与当前工作线程绑定的p,第二个参数max表示最多可以从全局运行队列中拿多少g到当前工作线程本地运行队列中来。

来看runtime/proc.go文件4663行代码:

// Try get a batch of G's from the global runnable queue.// Sched must be locked.func globrunqget(_p_ *p, max int32) *g {
   
       if sched.runqsize == 0 {  //全局运行队列为空        return nil    }    //根据p的数量平分全局运行队列中的goroutines    n := sched.runqsize / gomaxprocs + 1    if n > sched.runqsize { //上面计算n的方法可能导致n大于全局运行队列中的goroutine数量        n = sched.runqsize    }    if max > 0 && n > max {
   
           n = max   //最多取max个goroutine    }    if n > int32(len(_p_.runq)) / 2 {
   
           n = int32(len(_p_.runq)) / 2  //最多只能取本地队列容量的一半    }    sched.runqsize -= n    //直接通过函数返回gp,其它的goroutines通过runqput放入本地运行队列    gp := sched.runq.pop()  //pop从全局运行队列的队列头取    n--    for ; n > 0; n-- {
   
           gp1 := sched.runq.pop()  //从全局运行队列中取出一个goroutine        runqput(_p_, gp1, false)  //放入本地运行队列    }    return gp}

globrunqget首先根据全局运行队列中goroutine的数量、参数max以及_p_的本地运行队列的容量来计算应拿多少goroutine,然后将第一个g对象通过返回值的方式返回给调用函数,其它通过runqput放入当前工作线程的本地运行队列,上述代码在计算应从全局运行队列中拿多少goroutine时,根据p(gomaxprocs)做了负载均衡

从全局运行队列中没有寻找到可运行的goroutine,那就需要在当前工作线程的本地运行队列中寻找可运行goroutine了。

从上述代码中可以看到,工作线程的本地运行队列分为两部分,一部分是由p的runq、runqhead、runqtail这三个成员组成的无锁循环队列,最多可包含256个goroutine,另外一部分就是p的runnext,一个指向g结构体对象的指针,只有一个goroutine。

从当前工作线程的本地运行队列中寻找可运行goroutine是通过runqget完成的,寻找时,代码首先查看runnext成员是否为空,不为空则返回runnext所指的goroutine,并将runnext成员清零,反之则循环队列中查找goroutine。

来看runtime/proc.go文件4825行代码分析下runqget:

// Get g from local runnable queue.// If inheritTime is true, gp should inherit the remaining time in the// current time slice. Otherwise, it should start a new time slice.// Executed only by the owner P.func runqget(_p_ *p) (gp *g, inheritTime bool) {
   
       // If there's a runnext, it's the next G to run.    //从runnext成员中获取goroutine    for {
   
           //查看runnext成员是否为空,不为空则返回该goroutine        next := _p_.runnext           if next == 0 {
   
               break        }        if _p_.runnext.cas(next, 0) {
   
               return next.ptr(), true        }    }    //从循环队列中获取goroutine    for {
   
           h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with other consumers        t := _p_.runqtail        if t == h {
   
               return nil, false        }        gp := _p_.runq[h%uint32(len(_p_.runq))].ptr()        if atomic.CasRel(&_p_.runqhead, h, h+1) { // cas-release, commits consume            return gp, false        }    }}

可以看到,不管是从runnext还是从循环队列中取goroutine,都使用了cas操作,这里的cas是必须的,因为此时其它工作线程可能也在访问这两个成员,从当前线程偷取可运行的goroutine。

代码中对runqhead操作使用了atomic.LoadAcq和atomic.CasRel,分别提供了load-acquire和cas-release语义。

对atomic.LoadAcq语义如下:

  1. 原子读取,不管代码在哪个平台运行,保证在读取过程中不会有其它线程对该变量进行写入操作。

  2. 位于atomic.LoadAcq之后,对内存的读写需在atomic.LoadAcq读取完成后才可进行,编译器和CPU都不能打乱这个顺序。

  3. 当前线程执行atomic.LoadAcq时可读取到其它线程最近一次通过atomic.CasRel对同一个变量写入的值,同时,位于atomic.LoadAcq之后的代码,不管是读取那个内存地址中的值,都可以读取到其它线程中位于atomic.CasRel(对同一个变量的操作)之前的代码对内存的写入。

对atomic.CasRel语义如下:

  1. 原子的执行比较并交换的操作。

  2. 位于atomic.CasRel之前的代码对内存的读写必须在atomic.CasRel对内存写入之前完成,编译器和CPU都不能打乱这个顺序。

  3. 线程执行atomic.CasRel后,其它线程可通过atomic.LoadAcq读取同一个变量可读取到最新的值,同时,位于atomic.CasRel之前的代码对内存写入的值,可以被其它线程中位于atomic.LoadAcq(对同一个变量的操作)之后的代码读取到。

因为可能有多个线程并发读写runqhead,又需要依靠runqhead的值来读取runq数组的元素,所以要使用atomic.LoadAcq和atomic.CasRel保证上述语义。

那为啥读取p的runqtail不需使用atomic.LoadAcq和atomic.CasRel?

那是因为runqtail不会被其它线程修改,只能被当前工作线程修改,没别人动它也就不需要使用原子相关的操作。

cas操作需要注意的就是ABA问题,runqget上述两个使用cas的地方有没有这个问题?

答案是没有的,分析如下:

  1. 首先是对runnext的cas操作,只有跟_p_绑定的当前工作线程才会去修改runnext为非0值,其它工作线程只会将runnext从非0值修改为0值,跟_p_绑定的当前工作线程正在执行此处代码,所以在当前工作线程读取到值A之后,不可能有线程修改其值为B(0)再修改回A。

  2. 之后看对runq的cas操作,当前工作线程操作的是_p_的本地运行队列,只有跟_p_绑定的当前工作线程才会因为往该队列里添加goroutine而去修改runqtail,其它工作线程不会往该队列里添加goroutine,也就不会去修改runqtail,只会修改runqhead,所以当工作线程从runqhead读取到A之后,其它工作线程也就不可能会去修改runqhead值为B之后再第二次将其值修改为A(因为runqtail在这段时间之内不可能被修改,runqhead的值也就无法越过runqtail再绕回到A值),也就是说,代码从逻辑上就杜绝了引发ABA的条件。

至此,有关工作线程从全局运行队列和本地运行队列获取goroutine相关流程已经聊完了,下篇文章来聊聊从其它工作线程获取goroutine的流程。

以上仅为个人观点,不一定准确,能帮到各位那是最好的。

好啦,到这里本文就结束了,喜欢的话就来个三连击吧。

扫码关注公众号,获取更多优质内容。

 

Guess you like

Origin blog.csdn.net/luyaran/article/details/120947947