Goroutine调度时机-什么时候和什么情况下会发生调度?

原文地址:Goroutine调度时机-什么时候和什么情况下会发生调度?

Go调度器会在以下三种情况对goroutine进行调度:

  1. goroutine执行某个操作因条件不满足需要等待而发生的调度。

  2. goroutine主动调用Gosched()让出CPU而发生的调度。

  3. goroutine运行时间太长或长时间处于系统调用中,被调度器剥夺运行权而发生的调度。

本文主要通过下面这个例子来分析第一种情况,也就是因阻塞而发生的被动调度,其它两种情况会在之后的文章中详聊。

代码案例如下:

package mainfunc start(c chan int) {
   
       c <- 100}func main() {
   
       c := make(chan int)    go start(c)    <-c}

上述程序启动时,main goroutine会先创建一个无缓存的channel,之后启动一个goroutine,称之为g2,来向channel发送数据,main则去读取channel,在这两个goroutine对channel读写时,一定会发生阻塞,不是main读取阻塞就是g2写入阻塞。

先用gdb反汇编main,看下main反汇编代码:

0x44f4d0 <+0>: mov   %fs:0xfffffffffffffff8,%rcx0x44f4d9 <+9>: cmp   0x10(%rcx),%rsp0x44f4dd <+13>: jbe   0x44f549 <main.main+121>0x44f4df <+15>: sub   $0x28,%rsp0x44f4e3 <+19>: mov   %rbp,0x20(%rsp)0x44f4e8 <+24>: lea   0x20(%rsp),%rbp0x44f4ed <+29>: lea   0xb36c(%rip),%rax        0x44f4f4 <+36>: mov   %rax,(%rsp)0x44f4f8 <+40>: movq   $0x0,0x8(%rsp)0x44f501 <+49>: callq    0x404330 <runtime.makechan>  #创建channel0x44f506 <+54>: mov   0x10(%rsp),%rax0x44f50b <+59>: mov   %rax,0x18(%rsp)0x44f510 <+64>: movl   $0x8,(%rsp)0x44f517 <+71>: lea   0x240f2(%rip),%rcx        0x44f51e <+78>: mov   %rcx,0x8(%rsp)0x44f523 <+83>: callq   0x42c1b0 <runtime.newproc> #创建goroutine0x44f528 <+88>: mov   0x18(%rsp),%rax0x44f52d <+93>: mov   %rax,(%rsp)0x44f531 <+97>: movq   $0x0,0x8(%rsp)0x44f53a <+106>: callq   0x405080 <runtime.chanrecv1> #从channel读取数据0x44f53f <+111>: mov   0x20(%rsp),%rbp0x44f544 <+116>: add   $0x28,%rsp0x44f548 <+120>: retq   0x44f549 <+121>: callq 0x447390 <runtime.morestack_noctxt>0x44f54e <+126>: jmp   0x44f4d0 <main.main>

可以看到创建goroutine的go关键字被翻译成对runtime.newproc的调用,之前聊《知道Go第一个Goroutine是如何创建的麽?》聊过这个函数,这次再来简单看下此函数的流程:

  1. 切换到g0栈。

    扫描二维码关注公众号,回复: 13241293 查看本文章
  2. 分配g结构体对象。

  3. 初始化g对应的栈信息,并将参数拷贝到新g的栈上。

  4. 设置好g的sched成员,其包括调度g时所必须的sp、pc、bp等调度信息。

  5. 调用runqput将g放入运行队列。

  6. 返回。

前文聊的时候并未详细说明runqput是如何将g放入运行队列的,这次就来详细聊聊。

来看runtime/proc.go文件4746行代码分析runqput:

// runqput tries to put g on the local runnable queue.// If next is false, runqput adds g to the tail of the runnable queue.// If next is true, runqput puts g in the _p_.runnext slot.// If the run queue is full, runnext puts g on the global queue.// Executed only by the owner P.func runqput(_p_ *p, gp *g, next bool)   {
   
       if randomizeScheduler && next && fastrand() % 2 == 0  {
   
           next = false    }    if next  {
   
           //把gp放在_p_.runnext成员里,        //runnext成员中的goroutine会被优先调度起来运行    retryNext:        oldnext := _p_.runnext        if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp)))  {
   
                //有其它线程在操作runnext成员,需要重试            goto retryNext        }        if oldnext == 0  { //原本runnext为nil,所以没任何事情可做了,直接返回            return        }        // Kick the old runnext out to the regular run queue.        gp = oldnext.ptr() //原本存放在runnext的gp需要放入runq的尾部    }retry:    //可能有其它线程正在并发修改runqhead成员,所以需要跟其它线程同步    h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers    t := _p_.runqtail    if t - h < uint32(len(_p_.runq))  { //判断队列是否满了        //队列还没有满,可以放入        _p_.runq[t % uint32(len(_p_.runq))].set(gp)               // store-release, makes it available for consumption        //虽然没有其它线程并发修改这个runqtail,但其它线程会并发读取该值以及p的runq成员        //这里使用StoreRel是为了:        //1,原子写入runqtail        //2,防止编译器和CPU乱序,保证上一行代码对runq的修改发生在修改runqtail之前        //3,可见行屏障,保证当前线程对运行队列的修改对其它线程立马可见        atomic.StoreRel(&_p_.runqtail, t + 1)         return    }    //p的本地运行队列已满,需要放入全局运行队列    if runqputslow(_p_, gp, h, t) {
   
           return    }    // the queue is not full, now the put above must succeed    goto retry}

runqput的流程很清晰,先是尝试将gp放入_p_的本地运行队列,如果本地运行队列满了,则通过runqputslow将gp放入全局运行队列。

来看runtime/proc.go文件4784行代码,分析runqputslow:

// Put g and a batch of work from local runnable queue on global queue.// Executed only by the owner P.func runqputslow(_p_ *p, gp *g, h, t uint32) bool  {
   
       var batch [len(_p_.runq) / 2 + 1]*g  //gp加上_p_本地队列的一半    // First, grab a batch from local queue.    n := t - h    n = n / 2    if n != uint32(len(_p_.runq) / 2)  {
   
           throw("runqputslow: queue is not full")    }    for i := uint32(0); i < n; i++ { //取出p本地队列的一半        batch[i] = _p_.runq[(h+i) % uint32(len(_p_.runq))].ptr()    }    if !atomic.CasRel(&_p_.runqhead, h, h + n)  { // cas-release, commits consume        //如果cas操作失败,说明已经有其它工作线程从_p_的本地运行队列偷走了一些goroutine,所以直接返回        return false    }    batch[n] = gp    if randomizeScheduler {
   
           for i := uint32(1); i <= n; i++ {
   
               j := fastrandn(i + 1)            batch[i], batch[j] = batch[j], batch[i]        }    }    // Link the goroutines.    //全局运行队列是一个链表,这里首先把所有需要放入全局运行队列的g链接起来,    //减少后面对全局链表的锁住时间,从而降低锁冲突    for i := uint32(0); i < n; i++  {
   
           batch[i].schedlink.set(batch[i+1])    }    var q gQueue    q.head.set(batch[0])    q.tail.set(batch[n])    // Now put the batch on global queue.    lock(&sched.lock)    globrunqputbatch(&q, int32(n+1))    unlock(&sched.lock)    return true}

runqputslow先使用链表将从_p_的本地运行队列中取出来的一半连同gp串联起来,之后加锁成功后通过globrunqputbatch将该链表链入全局运行队列(全局运行队列是使用链表来实现的),runqputslow并未在一开始的时候就将全局运行队列锁住,而是等所有准备工作完成之后才锁住全局运行队列,这也是并发编程加锁的基本原则,也就是说要尽量减小锁的粒度,降低锁冲突的概率。

到这里呢,就聊完runqput是如何将g放入运行队列了,之后再来看main goroutine因读取channel而发生的阻塞流程。

从代码逻辑这边来看,是不知道main和g2谁先运行的,只是分析的话,就可以先来假设main创建完g2之后首先阻塞在读取channel操作上。

读取channel是通过调用runtime.chanrecv1实现的,本文主要关注的是读取过程中与调度相关的内容,具体细节是不关注的。

来看runtime/chan.go文件403行代码,来分析chanrecv1:

// entry points for <- c from compiled code//go:nosplitfunc chanrecv1(c *hchan, elem unsafe.Pointer) {
   
       chanrecv(c, elem, true)}// runtime/chan.go : 415func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
   
       ......    //省略部分的代码逻辑主要在判断读取操作是否可以立即完成,如果不能立即完成    //就需要把g挂在channel c的读取队列上,然后调用goparkunlock函数阻塞此goroutine    goparkunlock(&c.lock, waitReasonChanReceive, traceEvGoBlockRecv, 3)    ......}

chanrecv1直接调用chanrecv实现读取,chanrecv先验证channel中是否有数据可读,有则直接读取并返回,没有则需将当前goroutine挂入channel的读取队列之中,之后调用goparkunlock阻塞该goroutine。

来看runtime/proc.go文件304行代码,来分析goparkunlock:

// Puts the current goroutine into a waiting state and unlocks the lock.// The goroutine can be made runnable again by calling goready(gp).func goparkunlock(lock *mutex, reason waitReason, traceEv byte, traceskip int) {
   
       gopark(parkunlock_c, unsafe.Pointer(lock), reason, traceEv, traceskip)}// runtime/proc.go : 276// Puts the current goroutine into a waiting state and calls unlockf.// If unlockf returns false, the goroutine is resumed.// unlockf must not access this G's stack, as it may be moved between// the call to gopark and the call to unlockf.// Reason explains why the goroutine has been parked.// It is displayed in stack traces and heap dumps.// Reasons should be unique and descriptive.// Do not re-use reasons, add new ones.func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason     waitReason, traceEv byte, traceskip int) {
   
       ......    // can't do anything that might move the G between Ms here.    mcall(park_m) //切换到g0栈执行park_m函数}

goparkunlock直接调用gopark,gopark则调用mcall从main goroutine切到g0去执行park_m(mcall主要是保存当前goroutine现场,然后切到g0栈去调用作为参数传递给它的函数)。

来看runtime/proc.go文件2581行代码,来分析park_m:

// park continuation on g0.func park_m(gp *g) {
   
       _g_ := getg()    if trace.enabled {
   
           traceGoPark(_g_.m.waittraceev, _g_.m.waittraceskip)    }    casgstatus(gp, _Grunning, _Gwaiting)    dropg()  //解除g和m之间的关系    ......       schedule()}

park_m先将当前goroutine状态设置为_Gwaiting(因为当前goroutine正在等待其它goroutine往channel里写入数据),然后调用dropg接触g和m之间的关联,最后通过schedule进入调度循环。

schedule之前聊过,它会先从运行队列中挑出一个goroutine,之后调用gogo切到挑出的goroutine去运行。

因为main goroutine在读取channel被阻塞之前就已经将创建好的g2放入运行队列,所以此时schedule会将g2调度起来运行。

至此就完成一次从main到g2调度(当前场景假设只有一个工作线程在进行调度)。

g2入口函数是start,接下来反汇编start来g2写入channel阻塞流程。

start反汇编代码如下:

0x44f480 <+0>:mov   %fs:0xfffffffffffffff8,%rcx0x44f489 <+9>:cmp   0x10(%rcx),%rsp0x44f48d <+13>:jbe   0x44f4c1 <main.start+65>0x44f48f <+15>:sub   $0x18,%rsp0x44f493 <+19>:mov   %rbp,0x10(%rsp)0x44f498 <+24>:lea   0x10(%rsp),%rbp0x44f49d <+29>:mov   0x20(%rsp),%rax0x44f4a2 <+34>:mov   %rax,(%rsp)0x44f4a6 <+38>:lea   0x2d71b(%rip),%rax        0x44f4ad <+45>:mov   %rax,0x8(%rsp)0x44f4b2 <+50>:callq   0x404560 <runtime.chansend1> #写channel0x44f4b7 <+55>:mov   0x10(%rsp),%rbp0x44f4bc <+60>:add   $0x18,%rsp0x44f4c0 <+64>:retq   0x44f4c1 <+65>:callq    0x447390 <runtime.morestack_noctxt>0x44f4c6 <+70>:jmp   0x44f480 <main.start>

可看到编译器将对channel发送操作翻译成对runtime.chansend1的调用。

来看runtime/chan.go文件124行代码分析chansend1:

// entry point for c <- x from compiled code//go:nosplitfunc chansend1(c *hchan, elem unsafe.Pointer) {
   
       chansend(c, elem, true, getcallerpc())}// runtime/chan.go : 142func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
   
       ......    if sg := c.recvq.dequeue(); sg != nil {
   
           // Found a waiting receiver. We pass the value we want to send        // directly to the receiver, bypassing the channel buffer (if any).        //可以直接发送数据给sg        send(c, sg, ep, func() { unlock(&c.lock) }, 3)        return true    }    ......}// runtime/chan.go : 269func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
   
       ......    goready(gp, skip+1)}// runtime/proc.go : 310func goready(gp *g, traceskip int) {
   
       systemstack(func() {
   
           ready(gp, traceskip, true)    })}

channel发送和读取流程类似,如能立即发送则立即发送并返回,反之需阻塞,在此场景中,因main goroutine此时正挂在channel读取队列上等待数据,所以直接调用send发送数据给main goroutine,send则调用goready切到g0栈,之后调用ready来唤醒sg对应的goroutine,也就是正在读channel的main goroutine。

来看runtime/proc.go文件639行代码来分析ready:

// Mark gp ready to run.func ready(gp *g, traceskip int, next bool) {
   
       ......    // Mark runnable.    _g_ := getg()    ......    // status is Gwaiting or Gscanwaiting, make Grunnable and put on runq    casgstatus(gp, _Gwaiting, _Grunnable)    runqput(_g_.m.p.ptr(), gp, next) //放入运行队列    if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 {
   
           //有空闲的p而且没有正在偷取goroutine的工作线程,则需要唤醒p出来工作        wakep()    }    ......}

ready先将需唤醒的goroutine状态设置为_Grunnable,之后将其放入运行队列之中来等待调度器的调度,如果当前有空闲的p并且没有工作线程正在尝试从各个工作线程的本地运行队列偷取goroutine(没有处于spinning状态的工作线程)的话,那就需要wakep将空闲的p叫起来工作。

至此,main goroutine已被放入运行队列,但还未被调度起来运行,而g2在向channel写入数据完成之后,就从ready返回并退出了,由《非main goroutine的退出流程和调度循环》一文可知,g2退出过程中会在goexit0中调用schedule进入下一轮调度,从而将刚刚放入运行队列的main goroutine调度起来运行。

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

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

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

 

猜你喜欢

转载自blog.csdn.net/luyaran/article/details/120972078