地鼠宝宝的秩事异闻之调度器

Golang并发机制

调度器

在go的两级线程模型中,一部分调度任务由操作系统承担,而另一部分就由调度器完成。调度的对象就是go的三大核心元素:M、P、G。

基本结构

调度器的数据结构是一个结构体,但是不能单纯的说调度器就是一个结构体。结构体只是为了辅助调度,真正的调度行为还的靠调度函数来完成。

在go中调度器,调度器是这样声明的:var sched schedt

schedt是结构体类型:

type schedt struct {
    gcwaiting  uint32   //是否需要因一些任务而停止调度
    stopwait   int32    //需要停止但仍未停止的P的数量
    stopnote   note     //实现与stopwait相关的事件通知机制
    sysmonwait uint32   //停止调度期间,系统监控任务是否在等待
    sysmonnote note     //实现与sysmonwait相关的事件通知机制
}

在go运行时系统中,一些任务执行前需要停止调度。例如垃圾回收任务中的某些子任务,发起运行时恐慌的任务。下面我们将这类任务统称为串行运行时任务。上面的字段都是和串行运行时任务相关的。并且它们也是并发安全的。

暂停调度任务:主要与gcwaitingstopwaitstopnote字段有关。

  1. gcwaiting字段表示是否需要停止调度。在停止调度前,该值被设置为1;恢复调度前,该值被设置为0。
  2. 一些调度任务在执行时,一旦发现gcwaiting的值为1,就会把当前P的状态设置为Pgcstop,然后自减stopwait字段的值。
  3. 当自减后发现stopwait的值为0,说明所有P都进入了Pgcstop状态。然后就利用stopnote字段唤醒因等待调度停止而暂停的串行运行时任务。

暂停系统检测任务:主要与sysmonwaitsysmonnote字段相关。

  1. 串行运行时任务执行前,系统检测任务也要暂停。
  2. sysmonwait字段表示是否已暂停。0表示未暂停,1表示已暂停。
  3. 系统监测任务是一直执行的,它处于无限循环中。在每个循环的开始,系统监测程序都会检查调度情况。
  4. 一旦发现调度停止(gcwaiting的值不为0或所有P都已闲置),就会把sysmonwait字段的值设置为1,并利用sysmonnote字段暂停自身。
  5. 恢复调度之前,调度器若发现sysmonwait的值不为0,就把它置为0,并利用sysmonnote字段恢复系统监测任务的执行。

一轮调度

runtime包的proc.go文件中有一个叫schedule的函数。它便是一轮调度的真身。注意一轮调度只是调度的核心流程,并不是调度的全部。

  1. 在调度开始,判断当前M是否已被锁定。如果当前M已和某个G锁定,立即停止调度,并停止当前M(让它阻塞),直到与它锁定的G处于可运行状态时,才会被唤醒并继续运行锁定的G。停止当前M后,相关内核线程就不能再做其他事了,调度器也不再为这个M寻找可运行的G。

    if _g_.m.lockedg != 0 {
    stoplockedm()
    execute(_g_.m.lockedg.ptr(), false) // Never returns.
    }
  2. 如果当前G执行的是cgo调用的话,那么调度也会停止。

    // We should not schedule away from a g that is executing a cgo call,
    // since the cgo call is using the m's g0 stack.
    if _g_.m.incgo {
       throw("schedule: in cgo")
    }
  3. 判断是否有串行运行时任务正在等待执行,判断依据就是调度器的gcwaiting字段是否为0。如果gcwaiting不为0,则停止并阻塞当前M直到串行运行时任务结束,才继续执行后面的调度动作。串行运行时任务执行时需要停止Go的调度器,官方称次操作为Stop the world,简称STW

    top:
    if sched.gcwaiting != 0 {
        gcstopm()
        goto top
    }
  4. 接下来就是寻找可运行G的过程。首先试图获取执行踪迹读取任务的G。

    var gp *g
    var inheritTime bool
    if trace.enabled || trace.shutdown {
       gp = traceReader()
       if gp != nil {
           casgstatus(gp, _Gwaiting, _Grunnable)
           traceGoUnpark(gp, 0)
       }
    }
  5. 未果,试图获取执行GC标记任务的G。

    if gp == nil && gcBlackenEnabled != 0 {
    gp = gcController.findRunnableGCWorker(_g_.m.p.ptr())
    }
  6. 未果,从调度器的可运行G队列中获取可运行G。globrunqget函数负责从调度器的可运行G队列获取一个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.
    if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
        lock(&sched.lock)
        gp = globrunqget(_g_.m.p.ptr(), 1)
        unlock(&sched.lock)
    }
    }
  7. 未果,从本地P的可运行G队列中获取可运行G。runqget函数从本地P的可运行G队列获取一个G。这里说一下,runqget函数除了返回一个G之外,还会返回一个bool值。inheritTime值为true表示这个G要继承当前剩下的时间片。那么什么情况下runqget函数会返回true呢?当它返回的G来自本地P的runnext字段时,该函数就会返回true。也就是说,本地P的runnext字段中的G在执行时需要继承时间片。

    if gp == nil {
    gp, inheritTime = runqget(_g_.m.p.ptr())
    if gp != nil && _g_.m.spinning {
        throw("schedule: spinning with local work")
    }
    }
  8. 未果,全力查找可运行G。函数findrunnable会一直阻塞直到找到一个可运行的G。也就是说,这个函数返回时,一定是找到一个可运行G了。所谓不撞南墙不回头,说的就是它。为什么这里也有inheritTime?不难想象,这是因为全力寻找可运行G的过程中也会尝试从本地P那里获取可运行G。也就是说在findrunnable函数中也调用了runqget函数。

    if gp == nil {
    gp, inheritTime = findrunnable() // blocks until work is available
    }
  9. 找到的可运行G与某个M锁定。唤醒锁定的M来运行该G,然后继续为当前M寻找可运行G。goto top会回到第3步继续执行。

    if gp.lockedm != 0 {
       // Hands off own p to the locked m,
       // then blocks waiting for a new p.
       startlockedm(gp) //唤醒与gp锁定的M来执行gp
       goto top
    }
  10. 执行找到的这个可运行G。

    execute(gp, inheritTime)

    那么execute背后又是如何执行的呢?在该函数的最后调用了gogo函数,这是一个汇编函数,在asm_amd64.s文件中(“amd64”是系统架构)。

    gogo(&gp.sched)

    这里就不展开讲gogo函数了,说一下它的参数。sched是G结构体中的一个字段,之前讲M、P、G的时候没有讲G的结构。这里补上。

    type g struct {
       stack    stack       //G的栈
       m        *m          //运行G的M
       sched    gobuf       //寄存器现场
       goid     int64       //G的id
       lockedm muintptr //与这个G锁定的M
    }

    可以看到schedgobuf类型。gobuf也是一个结构体。

    type gobuf struct {
    sp   uintptr    //堆栈指针(stack pointer)
       pc   uintptr //程序计数器(program counter)
    g    guintptr
    }

    如果你知道程序时如何在计算机中执行的话(取指、译码、执行),那你就不会对这个结构体感到陌生。由于go的两级线程模型,所以G既要包含代码,又要包含用于执行该代码的栈以以及sppcSP指向的是保存程序数据的栈的栈顶,PC指向的是正在取指的指令。而gogo函数的作用就是从sched结构中恢复出上次G被调度器暂停时的寄存器现场(SPPC等),这样G就可以从上次暂停的地方继续执行了。

    以上就是一轮调度的全部过程了。

一轮调度的时机
  1. 用户程序启动时的一系列初始化工作之后,一轮调度流程会首次启动并使装载main函数的G被调度运行。
  2. G的阻塞、运行结束、退出系统调用,以及其栈的增长。
  3. 调用runtime.Gosched函数让当前G暂停运行,并让出CPU给其他G。
  4. 调用runtime.Goexit函数结束当前G的运行。
关于锁定

锁定M和G可以说是为cgo准备的。cgo是go程序和c程序之间的一座桥梁,使它们可以互相调用。有些C语言的函数库(比如OpenGL)会用到线程本地存储技术。它们会把一些数据存储到当前内核线程的私有缓存中。因此调用此类C函数库的G在特定时期内只能与同一个M产生关联,否则就可能丢失存储在某个内核线程私有缓存中的数据。这会对go的调度带来负面影响,如果不得不锁定,也应该尽量缩短锁定时间。

runtime包也提供了runtime.LockOSThread函数把当前G与某个M锁定,以及runtime.UnlockOSThread函数解除当前G与某个M的锁定。一个M只能与一个G锁定,反之亦然。多次调用runtime.LockOSThread只有最后一次调用有效。即便当前G没有与任何M锁定,调用runtime.UnlockOSThread函数也不会有任何副作用,它会直接返回。

总结

go的调度器并不是运行在某个专用内核线程中,调度器会运行在若干已存在的M(内核线程)中。也就是说,运行时系统中几乎所有的M都会参与调度任务的执行,它们共同实现了go调度器的调度功能。


全力查找可运行的G

函数runtime.findrunnable函数就是全力查找可运行G的流程。该函数会多次尝试从各处搜索可运行的G,甚至去别的P那里偷取。该函数一定会返回一个可运行的G。全力查找可运行G的流程分两个阶段:

第一阶段:
  1. 该流程依然会因串行运行时任务等待执行(gcwaiting不为0)而暂停和阻塞。

    top:
        if sched.gcwaiting != 0 {
            gcstopm()
            goto top
        }
  2. 获取执行终结器的G。一个终结器(或称终结函数)可以与一个对象关联,通过调用runtime.SetFinalizer函数就能产生这种关联。当一个对象变得不可达(未被任何其他对象引用)时,垃圾回收器在回收该对象之前,就会执行与之关联的终结函数。终结函数由一个专用的G执行,调度器在判定这个专用G已完成任务之后试图获取它,然后把它置为Grunnable状态并放入本地P的可运行G队列。

  3. 从本地P的可运行G队列获取G。获取到就返回。

    // local runq
    if gp, inheritTime := runqget(_p_); gp != nil {
    return gp, inheritTime
    }
  4. 从调度器的可运行G队列获取G。获取到就返回。

    // global runq
    if sched.runqsize != 0 {
       lock(&sched.lock)
       gp := globrunqget(_p_, 0)
       unlock(&sched.lock)
       if gp != nil {
        return gp, false
       }
    }
  5. 从网络I/O轮询器(netpoller)获取G。如果netpoller已被初始化且已有过网络I/O操作,那么调度器会尝试从netpoller那里获取一个G列表。表头的G会作为结果返回,其余的G放入调度器的可运行G队列。如果netpoller还未初始化或未进行过I/O操作,就跳过。这里的获取即使没有成功也不会阻塞,那么怎么看出来是非阻塞的呢?因为netpoll函数带入的参数是false,它返回的是一个G的列表。最后要说一下的是injectglist函数,它的参数是一个G列表。它做的工作也很简单:将列表中的所有G从Gwaiting状态转换到Grunnable状态,然后将这些G加入调度器的可运行G队列,最后如果还有在休息的P就把这些休息的P都叫起来干活。这里还要注意,给injectglist函数的参数是gp.schedlink.ptr(),这其实是gp列表的第二个G。也就是说从网络轮询器获取的G列表的第一个G直接返回了,其余G则进入了调度器的可运行G队列。

    if netpollinited() && atomic.Load(&netpollWaiters) > 0 && atomic.Load64(&sched.lastpoll) != 0 {
       if gp := netpoll(false); gp != nil { // 非阻塞的
           // netpoll returns list of goroutines linked by schedlink.
           injectglist(gp.schedlink.ptr())
           casgstatus(gp, _Gwaiting, _Grunnable)    //将获取的G的状态从Gwaiting变为Grunnable
           if trace.enabled {
            traceGoUnpark(gp, 0)
           }
           return gp, false
       }
    }
  6. 从其他P的可运行G队列偷取G。不过在盗窃之前,还需要满足两个条件。所谓盗亦有道是也。

    第一个条件是:除了本地P还有其他P在干活。如果除了自己,其他P都已经休息了,那也就没必要偷了,因为他们一定比你还穷(空闲P的可运行G队列一定为空)。这时直接去第二阶段。虽然正在进行系统调用、cgo调用、网络I/O等待、定时等待的G会与P分离导致P处于空闲状态,但是这些G变回Grunnable状态后会加入到调度器的可运行G队列,而不会回到本地P的可运行G队列。所以也没必要去P那里偷。

    procs := uint32(gomaxprocs)
    if atomic.Load(&sched.npidle) == procs-1 {
    goto stop
    }

    第二个条件是:当前M处于自旋状态,或者处于自旋状态的M的两倍比正在干活的P还要少,也就是说干活的P多,自旋的M少。如果当前P没有自旋,并且干活的P还少于自旋M的两倍,那也没必要偷,直接去第二 阶段。这主要是为了控制自旋M的数量,因为过多的自旋M会消耗大量CPU资源。

    if !_g_.m.spinning && 2*atomic.Load(&sched.nmspinning) >= procs-atomic.Load(&sched.npidle) {
    goto stop
    }

    如果满足了以上两个条件,就把当前M置于自旋状态,并开始偷取G。调度器会使用一种伪随机算法在全局P列表中选取P,然后试着从它的可运行G队列中偷一半的G到本地P的可运行G队列。选P偷G的过程会重复多次,成功即停止。如果成功,返回偷到的第一个G。注意,偷取G的过程中也会因串行运行时任务等待执行(gcwaiting不为0)而停止调度并阻塞。

    下面的代码有点难,我们稍微解释一下。你可以认为stealOrder是一个序列随机数生成器,它会返回一个包含很多随机数的容器,也就是enumenum.next就是取下一个随机数,enum.position就是真正的随机数。所以allp[enum.position]就是从全局P队列中随机选一个P。runqsteal是真正执行偷G(不是偷鸡)工作的函数。

    if !_g_.m.spinning {
       _g_.m.spinning = true    //将当前M置为自旋状态
       atomic.Xadd(&sched.nmspinning, 1)
    }
    for i := 0; i < 4; i++ {
       for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
           if sched.gcwaiting != 0 {    //有串行运行时任务等待执行
               goto top
           }
           stealRunNextG := i > 2 // first look for ready queues with more than 1 g
           if gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil {
               return gp, false
           }
       }
    }

    以上就是全力查找可运行G的第一阶段的全部内容。

第二阶段
  1. 获取执行GC标记任务的G。如果恰巧正处于GC标记阶段,且本地P可用于GC标记任务。那么调度器会把本地P持有的GC标记专用G置为Grunnable状态并返回这个G。

    if gcBlackenEnabled != 0 && _p_.gcBgMarkWorker != 0 && gcMarkWorkAvailable(_p_) {
       _p_.gcMarkWorkerMode = gcMarkWorkerIdleMode
       gp := _p_.gcBgMarkWorker.ptr()   //获取用于GC标记的专用G
       casgstatus(gp, _Gwaiting, _Grunnable)    //将gp并发安全的从Gwaiting状态转为Grunnable状态
       if trace.enabled {
           traceGoUnpark(gp, 0)
       }
       return gp, false
    }
  2. 再次从调度器的可运行G队列获取G。如果这次还是获取不到,就解除本地P与当前M的关联,并将该P放入调度器的空闲P列表。在这一步之前会将全局P列表(allp)复制一份,称为快照。

    allpSnapshot := allp //快照
    if sched.runqsize != 0 {
       gp := globrunqget(_p_, 0)    //从调度器的可运行G列表获取G
       return gp, false
    }
    if releasep() != _p_ {   //releasep函数解除P与M的关联
       throw("findrunnable: wrong p")
    }
    pidleput(_p_)    //将P放入调度器的空闲P列表
  3. 从全局P列表中的每个P的可运行G队列获取G。这里要迭代的全局P列表就是上一步的快照。只要发现某个P的可运行G队列不为空,就从调度器的空闲P列表中取出一个P。判定其可用后与当前M关联在一起,然后返回第一阶段重新搜索可运行的G。如果所有P的可运行队列都是空,就继续后面的搜索。

    这一步的意义是:正在干活的P还有活没干完,然而有些P却在休息,这怎么能忍?于是叫醒一个休息的P,然后给它找一个活干。下面的代码中省略了加锁和一些辅助步骤,仅展示了核心代码。

    for _, _p_ := range allpSnapshot {
       if !runqempty(_p_) {
           _p_ = pidleget()
           if _p_ != nil {
               acquirep(_p_)
               goto top
           }
           break
       }
    }
  4. 再次获取执行GC标记任务的G。如果正好处于GC标记阶段,且GC标记任务相关的全局资源可用。调度器就从空闲P列表中取出一个P,如果这个P持有GC标记专用G,就将该P与当前M关联,并从第二阶段开始继续执行。否则该P会被重新放回空闲P列表。下面的代码同样省略了加锁等步骤。

    if gcBlackenEnabled != 0 && gcMarkWorkAvailable(nil) {
       _p_ = pidleget()
       if _p_ != nil && _p_.gcBgMarkWorker == 0 {
           pidleput(_p_)
           _p_ = nil
       }
       if _p_ != nil {
           acquirep(_p_)
           // Go back to idle GC check.
           goto stop
       }
    }
  5. 再次从网络I/O轮询器(netpoller)处获取G。如果netpoller已被初始化,并且有过网络I/O操作。调度器会再次试图从netpoller那里获取一个G列表。注意,这里的获取是阻塞的,你可以看到这里netpoll函数带入的参数是true。只有当netpoller那里有可用G时,阻塞才会解除。如果netpoller还未被初始化或没进行过网络I/O操作,此步骤会跳过。此外这一步和第4步还有一点差别:只有当获取到一个空闲P的时候,才将获取的G返回,否则只是将获取的所有G都加入到调度器的可运行G队列。这是因为如果没有空闲的P,那么获得了G也执行不了,当前M还是只能先停下,也就没必要为这个M返回一个G了。下面的代码也省略了锁和辅助步骤。

    if netpollinited() && atomic.Load(&netpollWaiters) > 0 && atomic.Xchg64(&sched.lastpoll, 0) != 0 {
       gp := netpoll(true) // block until new work is available
       atomic.Store64(&sched.lastpoll, uint64(nanotime()))
       if gp != nil {
           _p_ = pidleget()
           if _p_ != nil {
               acquirep(_p_)
               injectglist(gp.schedlink.ptr())
               casgstatus(gp, _Gwaiting, _Grunnable)
               if trace.enabled {
                   traceGoUnpark(gp, 0)
               }
               return gp, false
           }
           injectglist(gp)
       }
    }

    尾声:

    第二阶段的搜索到此也就结束了。如果经历以上强劲的搜索任然找不到一个可运行的G,那么也就只好停止当前M了。等到该M再次被唤醒的时候,它还会从第一阶段开始继续搜索。

    stopm()
    goto top
自旋状态

自旋状态标示了M的一种工作状态。

  1. M处于自旋状态意味着它还没有找到可运行的G来运行。
  2. 无论是找到可运行的G,还是因未找到而被停止,当前M都会退出自旋状态。
  3. 一般情况下,运行时系统中至少会有一个自旋的M。
  4. 除非发现没有自旋的M,调度器不会新建或恢复一个M去运行新的G。
  5. 新建或恢复一个M时,它最初总是处于自旋状态。

启用或停止M

相关函数:

  • stopm():停止当前M的执行,直到有新的G变得可运行而被唤醒。
  • gcstopm():停止当前M的执行,串行运行时任务执行完毕后被唤醒。
  • stoplockedm():停止与某个G锁定的当前M的执行,直到该G变得可运行时被唤醒。
  • startlockedm(gp *g):唤醒与gp锁定的那个M,并然该M执行gp
  • startm(_p_ *p, spinning bool):唤醒或新建一个M去关联_p_并开始执行。

相关过程:

  1. 调度时发现当前M与某个G锁定了。调度器就会调用stoplockedm函数停止当前M。stoplockedm函数会先解除当前M与本地P的关联,并通过handoffp函数将这个P转手给其他M。hanfoffp函数会判断这个P是否有继续工作的必要,如果有,就调用startm函数唤醒一个M与该P关联,如无必要,就直接将该P放入空闲P列表。一旦P被转手,stoplockedm函数就会停止当前M的执行,并等待被唤醒。

  2. 当调度器为当前M找到一个可运行的G,但发现该G已经和某个M锁定了,就会调用startlockedm函数并将这个G作为参数传入。startlockedm函数会通过参数gp找到与这个G锁定的M,并强行把当前M的本地P转手给与该G锁定的M。这里的转手并不是调用handoffp函数,而是直接先解除当前M与本地P的关联,然后把这个P付给与该G锁定的M的nextp字段,将它们预联。之后startlockedm函数会调用notewakeup函数唤醒锁定的M,一旦M被唤醒,之前的预联就会变成关联,那么G也会被执行。最后,startlockedm函数还会调用stopm函数停止当前M。

  3. stopm函数会先把当前M放入调度器的空闲M列表,然后停止当前M。注意stopm函数并不会返回,而是停在其中,当M再次被唤醒的时候,会从stopm停下的地方继续执行。接下来有两件事要做。一是如果M是因GC任务而被唤醒,那么执行完该任务之后,当前M再次停止。否则关联与M预联的P,为M的执行做最后的准备。下面的代码省略了加锁和一些辅助代码。

    func stopm() {
    _g_ := getg()
    
    retry:
    mput(_g_.m) //将M放入空闲M列表
    notesleep(&_g_.m.park)
    noteclear(&_g_.m.park)
    if _g_.m.helpgc != 0 {  //GC任务
        // helpgc() set _g_.m.p and _g_.m.mcache, so we have a P.
        gchelper()
        // Undo the effects of helpgc().
        _g_.m.helpgc = 0
        _g_.m.mcache = nil
        _g_.m.p = 0
        goto retry
    }
    acquirep(_g_.m.nextp.ptr()) //关联与M预联的P
    _g_.m.nextp = 0
    }

    一旦M要停止,就会把它的本地P转手给别的M。一旦M被唤醒,就会先找到一个P与之关联,并且这个P一定是该M被唤醒之前由别的M预联给它的。如果handoffp函数无法把作为其参数的P转手给一个M,那么就把该P放入空闲P列表。

  4. 在调度过程中,如果有串行运行时任务等待执行,gcstopm函数就会被调用。该函数首先判断当前M是否处于自旋状态,如果是就退出自旋,并将调度器的自旋M数减一。一个将要停止的M理应脱离自旋状态。

    /*gcstopm*/
    if _g_.m.spinning {
       _g_.m.spinning = false
       if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 {
           throw("gcstopm: negative nmspinning")
       }
    }

    然后gcstopm函数会依次释放本地P,并将本地P的状态设置为Pgcstop。将调度器的stopwait字段减一,并在该值等于0的时候通过调度器的stopnote字段唤醒等待执行的串行运行时任务。最后调用stopm函数停止当前M并将其放入调度器的空闲M列表。下面的代码同样省略了加锁。

    /*gcstopm*/
    _p_ := releasep()
    _p_.status = _Pgcstop
    sched.stopwait--
    if sched.stopwait == 0 {
       notewakeup(&sched.stopnote)
    }
    stopm()
  5. 如果经历一轮调度后任然找不到一个可运行的G给当前M执行,那么调度程序会调用stopm函数停止当前M。也就是说此时已经没有多余的工作可做了,理应停掉一些M以节约资源。

  6. 所有调用因调用stopm函数停止的M,都可以通过调用startm函数唤醒。一个M被唤醒的原因总是有新工作要做。比如有了新的自由的P,或有了新的可运行G。如果调用startm函数传入的参数_p_为空,那么就从调度器的空闲P列表获取一个P作为M运行G的上下文环境。如果没有空闲的P,startm函数会直接返回,因为没有P,M也运行不了G。如果有幸得到了一个P,startm函数就会再从调度器的空闲M列表获取一个M,如果没有空闲的M就新建一个M。这个M会和P进行预联,并做好执行准备。下面是startm函数的核心代码,其中省略了加锁以及一些辅助操作。

    func startm(_p_ *p, spinning bool) {
    if _p_ == nil {
        _p_ = pidleget()
        if _p_ == nil {
            return
        }
    }
    mp := mget()
    if mp == nil {
        var fn func()
        if spinning {
            // The caller incremented nmspinning, so set m.spinning in the new M.
            fn = mspinning
        }
        newm(fn, _p_)
        return
    }
    // The caller incremented nmspinning, so set m.spinning in the new M.
    mp.spinning = spinning
    mp.nextp.set(_p_)
    notewakeup(&mp.park)
    }

    通过startm唤醒被stopm停止的M可以简化如下:stopm停止一个M并等待唤醒。startm将一个P与一个M预联。stopm将预联的P与M关联。

系统监测任务

系统监测任务主要有4个任务:

  • 在需要时抢夺符合条件的P和G。
  • 在需要时进行强制GC。
  • 在需要时清扫堆。
  • 在需要时打印调度器跟踪信息。
基本变量

系统监测任务就是sysmon函数。更确切的来说是该函数中的一段无限循环。在循环开始之前,先定义了一些与系统监测任务相关的额变量。我们来看一眼。

var forcegcperiod int64 = 2 * 60 * 1e9  //强制GC的时间间隔/2分钟/

/*sysmon*/
scavengelimit := int64(5 * 60 * 1e9)    //清扫堆的事件间隔/5分钟/

if debug.scavenge > 0 {
    // Scavenge-a-lot for testing.
    forcegcperiod = 10 * 1e6    //强制GC的时间间隔/10微秒/
    scavengelimit = 20 * 1e6    //清扫堆的事件间隔/20微秒/
}

//lastscavenge := nanotime()
//nscavenge := 0

//lasttrace := int64(0)
idle := 0 // how many cycles in succession we had not wokeup somebody
delay := uint32(0)
  1. 首先注意,forcegcperiod是定义在sysmon函数外边的。它的含义是强制执行GC的时间间隔,默认是2分钟。
  2. scavengelimit表示清扫堆的时间间隔,默认是5分钟。
  3. 如果我们设置环境变量GODEBUG,其值包含scavenge=1。接下来的if就会执行,那么forcegcperiod就会缩短为10微秒,scavengelimit会缩短为20微秒。这相当于开启了调试模式,仅供调试使用,千万不可用于go程序的正式运行。GODEBUG环境变量中也可以包含多个键值对,中间用逗号隔开。
  4. idle表示最近已连续有多少次系统监测任务执行但未能成功夺取P。一旦某次执行过程中成功夺取P,其值就会清零。
  5. delay表示系统监测任务具体的睡眠时间,单位为微秒。最大值为10000us,即10ms。
系统监测任务流程:for循环
  1. 开始之前先1一觉,睡多长时间由idle的值决定。一开始是睡20us,当idle大于50之后,每次都翻倍,最长不超过10ms。

    /*for*/
    if idle == 0 { // start with 20us sleep...
       delay = 20
    } else if idle > 50 { // start doubling the sleep after 1ms...
       delay *= 2
    }
    if delay > 10*1000 { // up to 10ms
       delay = 10 * 1000
    }
    usleep(delay)
  2. 睡醒之后,如果发现有串行运行时任务等待执行,或所有P都已空闲,也就是没活干了,那么就继续睡。睡之前将调度器的sysmonwait字段(希望你还记得它)设置为1,表示系统监测任务已停止。这次睡的时间是forcegcperiodscavengelimit中较小值的一半。也就是说,是强制GC时间间隔和清扫堆时间间隔中较短时间间隔的一半。睡觉已是不得已,能少睡就不多睡。睡完之后调度器的sysmonwait字段要清零,还会把idle清零,并将delay设为初始值20。下面的代码同样省略了锁和一些辅助步骤。

    if debug.schedtrace <= 0 && (sched.gcwaiting != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs)) {
       if atomic.Load(&sched.gcwaiting) != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs) {
           atomic.Store(&sched.sysmonwait, 1)
           maxsleep := forcegcperiod / 2
           if scavengelimit < forcegcperiod {
               maxsleep = scavengelimit / 2
           }
           notetsleep(&sched.sysmonnote, maxsleep)
           atomic.Store(&sched.sysmonwait, 0)
           idle = 0
           delay = 20
       }
    }
  3. 接下来就是抢夺P和G的过程。这个过程分为两步:首先如果网络I/O轮询器已经初始化,并且距上次通过网络轮询器获取G的时间已超过10ms,那么就记录此次获取的时间并通过网络I/O轮询器获取一个可运行G,否则跳过此步。其次是从调度器那里抢夺符合条件的P和G,这一步由retake函数完成,我们稍后再详细的解释它。目前我们只看到抢夺成功则idle会清零,失败则idle自加一,这和我们之前的说法是一致的。现在先说说从网络轮询器获取G的一些细节。

    //从网络I/O轮询器获取一个可运行G
    lastpoll := int64(atomic.Load64(&sched.lastpoll)) //上次从网络轮询器获取G的时间
    now := nanotime()    //当前时间
    if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
       atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now)) //更新调度器的lastpoll值
       gp := netpoll(false) // 非阻塞 - 返回一个goroutine列表
       if gp != nil {
           incidlelocked(-1)
           injectglist(gp)
           incidlelocked(1)
       }
    }
    
    //从调度器那里抢夺符合条件的P和G
    if retake(now) != 0 {
       idle = 0
    } else {
       idle++
    }

    在从网络轮询器获取到G并判定gp不为空之后,执行了三句代码。其中第二句的injectglist函数我们之前已经说过了,它会把G都加入到调度器的可运行G队列,并启动一个空闲的P来运行G。那么前面的incidlelocked函数有什么用呢?其实这个函数时”inc idle locked “这三个单词组合而成,意思是增加因锁定而空闲的M的数量。这个数量其实是调度器的nmidlelocked字段,之前讲调度器结构的时候没有提到它,这里补上几个重要的。

    type schedt struct {
       nmidle       int32   //空闲M数量
       nmidlelocked int32   //因锁定而停止的M的数量
       mnext        int64   //已创建的M的数量,也是下一个M的ID号
       nmsys        int32   //执行系统监测任务的M的数量
       nmfreed      int64   //已被释放的M的数量
    }

    incidlelocked函数十分简单,它就做了两件事:一是将参数加到调度器的nmidlelocked字段上;而是如果参数大于0,就调用checkdead函数检查是否发生死锁。

    func incidlelocked(v int32) {
    lock(&sched.lock) //加锁
    sched.nmidlelocked += v
    if v > 0 {
        checkdead() //检查死锁
    }
    unlock(&sched.lock) //解锁
    }

    那么为什么只在参数大于0的情况下才去检查死锁呢?我们还得去看看checkdead函数。正在的cheakdead当然不会像下面这么短,我们先略去其他情况,单看这一种情况。mcount函数返回的是目前系统中存在的M的数量。显然run代表的就是还在运行的M的数量。 M=MMMM 运 行 中 的 M 数 = M 总 数 − 空 闲 M 数 − 锁 定 M 数 − 执 行 系 统 监 测 任 务 的 M 数 。很明显当运行中的M数大于0时不会发生死锁,但是如果运行中的M的数量小于0就会发生死锁,程序崩溃。

    现在可以解释为什么incidlelocked函数只在参数大于0的时候去检查死锁了。因为参数大于0时,nmidlelocked值会增大,这时就有可能使run的值变成负数。也就是说这个M的锁定可能使系统中没有运行的M而发生死锁,当然有必要查看一下是否发生了死锁。如果incidlelocked函数的参数小于0,那么nmidlelocked的值会变小,而run的值只会更大,如果run本来就大于0,那么更不可能因此而发生死锁了,当然也就没必要检查了。毕竟浪费可耻。

    func checkdead() {
    run := mcount() - sched.nmidle - sched.nmidlelocked - sched.nmsys
    if run > 0 {
        return
    }
    if run < 0 {
        throw("checkdead: inconsistent counts")
    }
    }
    func mcount() int32 {
    return int32(sched.mnext - sched.nmfreed)
    }

    解释了死锁以后,我们再回过头来看在通过网络轮询器获得G之后,将这些G放入调度器的全局G队列前后两次调用incidlelocked函数到底有什么用呢?想象一下,如果在injectglist函数在完成它的工作之前,某个M从系统调用返回并执行完了它的G,此时它掐指一算,系统中没有工作可做了,也没有运行的M了,于是死锁就发生了。然而这时的injectglist函数就是有冤无处述,有苦说不出了。就因为送货(G)慢了点,人家就关门了,也不等等它。所以现在你应该知道了incidlelockd(-1)的作用就是为了避免这种死锁的情况。将nmidlelocked减一后,run的值怎么都是大于等于1,不会小于0。从而避免从网络轮询器获得的G在运行之前发生死锁。也就是说假装还有一个M在运行,但是真实的情况肯定不是这样,所以等到injectglist函数完成它的工作后,就要再次调用incidlelocked(1)来恢复系统真实的样子。

  4. 如果当前GC未执行,且距上一次执行已超过GC最大时间间隔,系统监测程序就会恢复专用于强制GC的G,并把它放入调度器的可运行G队列。GC最大时间间隔就是forcegcperiod的值,初始值为2分钟,前面也讲过了。之前我们也讲过,用于强制GC的G是一个专用G,它在调度器初始化时就开始运行了,只不过一般处于暂停状态,只有系统监测程序可以恢复它。下面的代码省略了加锁。

    if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {
       forcegc.idle = 0
       forcegc.g.schedlink = 0
       injectglist(forcegc.g)
    }

    上面代码中的forcegc就是强制GC的专用G。它在runtime2.go中定义,类型是forcegcstateforcegcstate是一个结构体类型,其中封装了一个G。

    var forcegc forcegcstate
    type forcegcstate struct {
    lock mutex
    g    *g
    idle uint32
    }
  5. 如果距离上一次清扫堆的时间已经超过了清扫堆的时间间隔的一半,也就是scavengelimit的二分之一,就会执行清扫堆的工作,清扫堆会把一段时间内未用的堆内存还给操作系统。而这里的一段时间也就是scavengelimit的值,初始为5分钟。

    if lastscavenge+scavengelimit/2 < now {
       mheap_.scavenge(int32(nscavenge), uint64(now), uint64(scavengelimit))
       lastscavenge = now
       nscavenge++
    }
  6. 如果程序运行之前设置了GODEBUG环境变量,并且包含schedtrace=x,那么系统监测程序就会每过x毫秒打印一次调度器跟踪信息。这的x就是打印周期。

    if debug.schedtrace > 0 && lasttrace+int64(debug.schedtrace)*1000000 <= now {
       lasttrace = now
       schedtrace(debug.scheddetail > 0)
    }
抢夺P和G:retake

之前在全力查找可运行G(findrunnable函数)的时候就有过偷G的行为,到了retake这里就变成明抢了。调度器为了并发也是无所不用其极了。闲言少叙,看看retake函数的流程。

  1. retake函数开始处,首先初始化了一个变量n := 0,它用来记录成功抢夺到P的次数。而最后retake函数的返回值也是这个n,所以在sysmon函数中我们能够用retake函数的返回值是否大于0来判断抢夺P是否成功。

  2. 接下来是对全局P列表中所有P进行迭代,并在可以抢夺的时候将P抢过来。不过在这之前,首先要初始化一些变量。那么为什么从全局P列表取出的P还要进行为空判断呢?这是为了防止此时调用了procresize函数增加了P的最大数量,虽然数量已经涨上去了,但实际上P还没有被创建出来,导致获取的P为空。procresize函数我们稍后再详细解释。

    /* for i := 0; i < len(allp); i++ { */
    _p_ := allp[i]
    if _p_ == nil {
       // This can happen if procresize has grown
       // allp but not yet created new Ps.
       continue
    }
    pd := &_p_.sysmontick
    s := _p_.status  //P的状态

    然后暂存了P的两个字段。早先讲P的结构时,提到过status字段,表示P的状态。我们再来看一眼和这里有关的P的其他字段。

    type p struct {
    schedtick   uint32     // 调度计数
    syscalltick uint32     // 系统调用计数
    sysmontick  sysmontick // sysmon持有的调用计数备份
    }

    这里要说的是P结构体的sysmontick字段,该字段的类型也是sysmontick,定义如下:

    type sysmontick struct {
    schedtick   uint32  //调度计数
    schedwhen   int64   //调度时刻
    syscalltick uint32  //系统调用计数
    syscallwhen int64   //系统调用时刻
    }

    其实sysmontick结构中的syscalltick字段和syscallwhen字段在P结构体中也有,那么为什么这里还要重复存一份呢?目的就是为了备份,当发现sysmontick中的syscalltick值与P中的不一样是,就说明新的系统调用发生了,此时就可以更新syscallwhen字段为当前时间,记录下此次系统调用的时刻了。

  3. 抢夺需要根据P的状态做不同的处理。首先,如果P的状态是Psyscall,说明P正在进行系统调用。这种情况下对P的抢夺过程我们分三个小步骤来说明。

    第一小步:判断当前P的系统调用计数和备份的系统调用计数是否相等。如果相等就继续后面的小步骤,如果不相等,要更新备份,并更新最近一次系统调用的时刻。那么为什么要在这两个计数不同步的时候更新备份,并放弃抢夺P呢?我们在第二小步中再解释。

    /* if s == _Psyscall { */
    t := int64(_p_.syscalltick)
    if int64(pd.syscalltick) != t {
       pd.syscalltick = uint32(t)
       pd.syscallwhen = now
       continue
    }

    第二小步:这一步的目的很明显,就是要在某些条件下放弃对P的抢夺。毕竟土匪也有三不抢嘛,不抢孤寡妇孺,不抢。。。不对,回来。要想放弃对P的抢夺,条件还是很严苛的,要同时满足三个条件:一是P的可运行G队列为空不抢,因为没活干的P抢过来也没用,所谓穷人不抢。二是有自旋的M或者有休息的P不抢,因为有自旋的M说明有M正在全力查找可运行的G,该P的可运行G队列一定会被全力查找可运行G的流程找到,就不用系统监测程序插手了,所谓各有各的山头,咱们井水不犯河水;而有休息的P你还抢我干啥,把休息的P叫醒干活不就完了吗。三是距离上一次系统调用的时间不足10ms不抢,注意,重点来了,这个条件中用到的上次系统调用时间正是备份中的syscallwhen字段,现在我们可以回答第一小步最后的问题了。因为如果P的系统调用计数和备份中的不同步,说明在此次系统调用之前,已经有人捷足先登进行过系统调用了,那么最新的系统调用的时间也就必定不是备份中syscallwhen字段记录的时刻,这就会导致这里我们用备份中的syscallwhen字段来判断距离上次系统调用的时间间隔是否大于10ms是不准确的。所以在第一小步中一旦发现P的系统调用计数和备份中的不同步,就应该更新备份,并且忽略后面的步骤。

    if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
       continue
    }

    第三小步:通过了前面严苛的条件判断之后,就可以正式抢夺P了,看来强盗也是蛮惨的。抢夺P的过程就是将P转手。抢夺过程包裹在incidlelocked函数调用之间的作用和for循环”>系统调度的第3步是一样的,那里已经讲得很清楚了。然后要将P的状态转换为空闲状态(Pidle),并自增记录成功抢夺P次数的变量n以及P系统调用计数,最后通过handoffp函数转手这个P。

    incidlelocked(-1)
    if atomic.Cas(&_p_.status, s, _Pidle) {
       n++
       _p_.syscalltick++
       handoffp(_p_)
    }
    incidlelocked(1)
  4. 这里是当P的状态为Prunning时的抢夺过程。也分为三个小步骤。第一小步我们刚刚解释过,和前面是一样的。这里说说第二小步和第三小步。

    /* else if s == _Prunning { */
    //#第一小步
    t := int64(_p_.schedtick)
    if int64(pd.schedtick) != t {
       pd.schedtick = uint32(t)
       pd.schedwhen = now
       continue
    }
    //#第二小步
    if pd.schedwhen+forcePreemptNS > now {
       continue
    }
    //#第三小步
    preemptone(_p_)

    第二小步中用到了forcepreemptNS,它是有” force preempt NS “组成,意思是强制抢占P的纳秒数,NS是单位,纳秒。它在retake函数外面定义,是一个常量。这一步是说距上次系统调用的时间间隔不足强制抢占P的时间间隔,就放弃抢占P。换句话说,当这个条件不满足时,说明这个P(准确的说是这个P当前运行的G)已经运行太长时间了,应该把它停止,并把运行机会让给其他G,以保证公平。所谓劫富济贫,怎么能让你一个人这么富有呢,是不是?

    const forcePreemptNS = 10 * 1000 * 1000 // 10ms

    最后是调用preemptone函数抢占该P,这也是go抢占式调度的体现。不过该函数只能告知在这个P上运行的G应该停止了。首先它不一定能正确的告知正确的G,其次即使告知被正确传递给了正确的G,这个G也可能忽略掉这个告知。也就是说preemptone函数只能告诉你我尽力而为,既不能保证告知正确到达,也不能保证那个G做出相应。那么它是如何调度的呢?其实调度就一行代码:

    gp.stackguard0 = stackPreempt

    go程序在执行G的每次调用时,都会通过比较当前堆栈指针和G的stackguard0字段来判断栈溢出。这里将当前G的stackguard0字段赋值为stackPreempt就会该G在下一次函数调用时栈空间检查失败,接下来就是一些列的函数调用,最终将这个G调度出去。在讲述这一些列函数调用之前,我们先来认识一下stackPreempt。它是在stack.go中定义的一个常量。在64位机器上,stackPreempt的值是0xfffffffffffffade,在32位机器上它的值是0xfffffade。表示的是栈指针sp的最大值,所以现在你知道为什么将stackguard0的值设置成它就能导致栈溢出了吧。它的计算出来需要用到uintptrMask,它是一个指针掩码,也就是一个所有位全为1的指针,32位机器上是0xffffffff,64位机器上是0xffffffffffffffff。在计算uintptrMask时用到的sys.PtrSizesys包中的stubs.go文件中定义。^uintptr(0)得到的是一个各位都为1的值,32位机器山就是32个1,64位机器上就是64个1。左移63位后,如果是32位机器,结果就是0,接下来4右移0位还是4;64位机器结果是1,4右移1位后结果是8。所以PtrSize表示的就是一个指针长度的字节数。而一个字节的长度是8,所以在计算uintptrMask时用8乘以sys.PtrSize得到的就是一个指针的位数。对于uintpteMask的计算可以类比十进制,在十进制下将1左移4位再减1,结果就是999感冒灵。

    /* stack.go */
    const (
       uintptrMask = 1<<(8*sys.PtrSize) - 1
    stackPreempt = uintptrMask & -1314
    )
    /* package sys */
    const PtrSize = 4 << (^uintptr(0) >> 63)

    绕了这么大一圈,就为了得到一把f,也是闲的发慌。现在来看看栈空间检查失败后的一些列函数调用:

    morestack() -> newstack() -> gopreempt_m() -> goschedImpl() -> schedule()
    • 首先是会触发morestack函数的调用,该函数在asm_armd64.s汇编文件中定义,在该函数中会调用newstack函数。

    • newstack函数在stack.go文件中定义。它会在特定的条件下调用gopreempt_m函数尽力而为也就是从这里开始的。

    • gopreempt_m函数在proc.go中定义,它的参数是需要停止的G。它就干了一件事,调用goschedImpl函数。

    • goschedImpl函数也在proc.go文件中,参数是要停止的G。该函数首先把这个G从Grunning状态转到Grunnable状态。然后调用dropg函数解除这个G与当前M的关联。再把这个G放入调度器的可运行G队列,最后调用schedule函数进行一轮调度,为当前P找一个新的可运行G来运行。至此抢占结束。下面的代码省略了加锁和一些辅助步骤。

      func goschedImpl(gp *g) {
      casgstatus(gp, _Grunning, _Grunnable)
      dropg()
      globrunqput(gp)
      schedule()
      }

变更P的最大数量

前面曾说过调用runtime.GOMAXPROCS函数可以改变P的最大数量。该函数首先会检查参数值的合法性:

  • 如果传入的新值大于256,那么新值会被替换成256。也就是说无论你传入多大的值,最终都不会超过256。
  • 如果传入的新值不是整数或者与旧值相同,那么函数会忽略更新并直接返回旧值。

通过了检查之后,该函数会先通过调度器停止一切调度工作(Stop the world)。然后暂存新值,进入P最大数量的变更流程,也就是调用procresize函数。该函数在变更完P的最大数量后也会重启调度工作(Start the World)。

变更P最大数量流程:

  1. 首先再检查一遍新值和旧值,如果不合法就引发一个运行时恐慌,并终止该流程。这也是go为变更P的最大数量上的第二道保险,由于有第一道保险,所以这一道保险几乎是不会被触发的。

    /* func procresize(nprocs int32) *p { */
    old := gomaxprocs
    if old < 0 || nprocs <= 0 {
       throw("procresize: invalid arg")
    }
  2. 如果新值比全局P列表的长度大,则增长全局P列表切片(allp)。如果容量够就扩容,如果容量不够就新建一个新的切片,并把全局P列表中的P都拷贝到新切片中。下面的代码省略了加锁。

    if nprocs > int32(len(allp)) {
       if nprocs <= int32(cap(allp)) {
           allp = allp[:nprocs]
       } else {
           nallp := make([]*p, nprocs)
           copy(nallp, allp[:cap(allp)])
           allp = nallp
       }
    }
  3. 假设新值为I,接下来对全局P列表的前I个P进行检查和初始化。如果全局P列表中的P的数量不够,就新建相应数量的P,并加入到全局P列表中。新创建的P会处于Pgcstop状态,表示它还不能使用。

  4. 假设旧值为J,程序还会对全局P列表中第I+1到第J个P(如果有的话)进行清理。首先要把P的可运行G队列中的G放入调度器可运行G队列的头部。其次将P的runnaex字段中的G(如果有的话)放入调度器的可运行G队列的头部。然后是将P持有的GC标记专用G从Gwaiting状态转到Grunnable状态并放入调度器的可运行G队列末尾。还需要调用gfpurge函数将P的自由G列表的所有G都转移到调度器的自由G列表中。最后将P设置为Pdead状态,以便之后进行销毁。之所以不能在这里立即销毁,是因为它们可能被正在进行系统调用的M引用,如果现在就销毁,就会在那些M完成系统调用时造成错误。下面的代码是经过了大量省略的。

    for i := nprocs; i < old; i++ {
       p := allp[i]
       // #1
       for p.runqhead != p.runqtail {
           // pop from tail of local queue
           p.runqtail--
           gp := p.runq[p.runqtail%uint32(len(p.runq))].ptr()
           // push onto head of global queue
           globrunqputhead(gp)  //将gp加入调度器可运行G队列头部
       }
       // #2
       if p.runnext != 0 {
           globrunqputhead(p.runnext.ptr()) //将gp加入调度器可运行G队列头部
           p.runnext = 0
       }
       // 3
       if gp := p.gcBgMarkWorker.ptr(); gp != nil {
           casgstatus(gp, _Gwaiting, _Grunnable)
           globrunqput(gp) //将gp加入调度器可运行G队列尾部
           p.gcBgMarkWorker.set(nil)
       }
       // #4
       gfpurge(p) //将P的自由G列表的所有G都转移到调度器的自由G列表
       // #5
       p.status = _Pdead
    }
  5. 经过一番清理之后,执行procresize函数的M的P也可能已经被清理掉了,但是当前M不能没P。屁(P)都没了还让我怎么干活?所以如果侥幸这个P没有被清理掉就把P还给当前M,如果不幸已经被清理了就把全局P列表(allp)中的第一个P给它。

    _g_ := getg()
    if _g_.m.p != 0 && _g_.m.p.ptr().id < nprocs {
       // continue to use the current P
       _g_.m.p.ptr().status = _Prunning
    } else {
       // release the current P and acquire allp[0]
       p := allp[0]
       p.m = 0
       p.status = _Pidle
       acquirep(p)
    }
  6. 最后,程序再次遍历前I个P,也就是新的全局P列表中的所有P,但是会跳过当前执行procresize函数的M的P。如果它的可运行G队列为空,就把它加入调度器的空闲P列表。否则尝试拿一个M来与这个P关联,成不成功不管,然后把它放入本地的可运行P列表。这样就得到了一个拥有可运行G的P列表,函数最后会将这个拥有可运行G的P列表作为结果返回给调用者。负责重启调度工作的程序会检查这个列表中的P,以保证它们一定能与一个M产生关联。随后程序会让与这些P关联的M都运行起来。

    var runnablePs *p
    for i := nprocs - 1; i >= 0; i-- {
       p := allp[i]
       if _g_.m.p.ptr() == p {  //跳过当前执行procresize函数的M的P
           continue
       }
       p.status = _Pidle
       if runqempty(p) {
           pidleput(p) //把P加入调度器的空闲P列表
       } else {
           p.m.set(mget())
           p.link.set(runnablePs)
           runnablePs = p
       }
    }
    
    return runnablePs

调度器的相关内容就先说到这里。其中大部分代码都是经过省略的,只留下了主要逻辑。阅读时可以对照源码查看。

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值