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运行时系统中,一些任务执行前需要停止调度。例如垃圾回收任务中的某些子任务,发起运行时恐慌的任务。下面我们将这类任务统称为串行运行时任务。上面的字段都是和串行运行时任务相关的。并且它们也是并发安全的。
暂停调度任务:主要与gcwaiting
、stopwait
、stopnote
字段有关。
gcwaiting
字段表示是否需要停止调度。在停止调度前,该值被设置为1;恢复调度前,该值被设置为0。- 一些调度任务在执行时,一旦发现
gcwaiting
的值为1,就会把当前P的状态设置为Pgcstop
,然后自减stopwait
字段的值。 - 当自减后发现
stopwait
的值为0,说明所有P都进入了Pgcstop
状态。然后就利用stopnote
字段唤醒因等待调度停止而暂停的串行运行时任务。
暂停系统检测任务:主要与sysmonwait
和sysmonnote
字段相关。
- 串行运行时任务执行前,系统检测任务也要暂停。
sysmonwait
字段表示是否已暂停。0表示未暂停,1表示已暂停。- 系统监测任务是一直执行的,它处于无限循环中。在每个循环的开始,系统监测程序都会检查调度情况。
- 一旦发现调度停止(
gcwaiting
的值不为0或所有P都已闲置),就会把sysmonwait
字段的值设置为1,并利用sysmonnote
字段暂停自身。 - 恢复调度之前,调度器若发现
sysmonwait
的值不为0,就把它置为0,并利用sysmonnote
字段恢复系统监测任务的执行。
一轮调度
在runtime
包的proc.go
文件中有一个叫schedule
的函数。它便是一轮调度的真身。注意一轮调度只是调度的核心流程,并不是调度的全部。
在调度开始,判断当前M是否已被锁定。如果当前M已和某个G锁定,立即停止调度,并停止当前M(让它阻塞),直到与它锁定的G处于可运行状态时,才会被唤醒并继续运行锁定的G。停止当前M后,相关内核线程就不能再做其他事了,调度器也不再为这个M寻找可运行的G。
if _g_.m.lockedg != 0 { stoplockedm() execute(_g_.m.lockedg.ptr(), false) // Never returns. }
如果当前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") }
判断是否有串行运行时任务正在等待执行,判断依据就是调度器的
gcwaiting
字段是否为0。如果gcwaiting
不为0,则停止并阻塞当前M直到串行运行时任务结束,才继续执行后面的调度动作。串行运行时任务执行时需要停止Go的调度器,官方称次操作为Stop the world,简称STW。top: if sched.gcwaiting != 0 { gcstopm() goto top }
接下来就是寻找可运行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) } }
未果,试图获取执行GC标记任务的G。
if gp == nil && gcBlackenEnabled != 0 { gp = gcController.findRunnableGCWorker(_g_.m.p.ptr()) }
未果,从调度器的可运行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) } }
未果,从本地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") } }
未果,全力查找可运行G。函数
findrunnable
会一直阻塞直到找到一个可运行的G。也就是说,这个函数返回时,一定是找到一个可运行G了。所谓不撞南墙不回头,说的就是它。为什么这里也有inheritTime
?不难想象,这是因为全力寻找可运行G的过程中也会尝试从本地P那里获取可运行G。也就是说在findrunnable
函数中也调用了runqget
函数。if gp == nil { gp, inheritTime = findrunnable() // blocks until work is available }
找到的可运行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 }
执行找到的这个可运行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 }
可以看到
sched
是gobuf
类型。gobuf
也是一个结构体。type gobuf struct { sp uintptr //堆栈指针(stack pointer) pc uintptr //程序计数器(program counter) g guintptr }
如果你知道程序时如何在计算机中执行的话(取指、译码、执行),那你就不会对这个结构体感到陌生。由于go的两级线程模型,所以G既要包含代码,又要包含用于执行该代码的栈以以及
sp
和pc
。SP
指向的是保存程序数据的栈的栈顶,PC
指向的是正在取指的指令。而gogo
函数的作用就是从sched
结构中恢复出上次G被调度器暂停时的寄存器现场(SP
、PC
等),这样G就可以从上次暂停的地方继续执行了。以上就是一轮调度的全部过程了。
一轮调度的时机
- 用户程序启动时的一系列初始化工作之后,一轮调度流程会首次启动并使装载
main
函数的G被调度运行。 - G的阻塞、运行结束、退出系统调用,以及其栈的增长。
- 调用
runtime.Gosched
函数让当前G暂停运行,并让出CPU给其他G。 - 调用
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的流程分两个阶段:
第一阶段:
该流程依然会因串行运行时任务等待执行(
gcwaiting
不为0)而暂停和阻塞。top: if sched.gcwaiting != 0 { gcstopm() goto top }
获取执行终结器的G。一个终结器(或称终结函数)可以与一个对象关联,通过调用
runtime.SetFinalizer
函数就能产生这种关联。当一个对象变得不可达(未被任何其他对象引用)时,垃圾回收器在回收该对象之前,就会执行与之关联的终结函数。终结函数由一个专用的G执行,调度器在判定这个专用G已完成任务之后试图获取它,然后把它置为Grunnable
状态并放入本地P的可运行G队列。从本地P的可运行G队列获取G。获取到就返回。
// local runq if gp, inheritTime := runqget(_p_); gp != nil { return gp, inheritTime }
从调度器的可运行G队列获取G。获取到就返回。
// global runq if sched.runqsize != 0 { lock(&sched.lock) gp := globrunqget(_p_, 0) unlock(&sched.lock) if gp != nil { return gp, false } }
从网络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 } }
从其他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
是一个序列随机数生成器,它会返回一个包含很多随机数的容器,也就是enum
。enum.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的第一阶段的全部内容。
第二阶段
获取执行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 }
再次从调度器的可运行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列表
从全局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 } }
再次获取执行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 } }
再次从网络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的一种工作状态。
- M处于自旋状态意味着它还没有找到可运行的G来运行。
- 无论是找到可运行的G,还是因未找到而被停止,当前M都会退出自旋状态。
- 一般情况下,运行时系统中至少会有一个自旋的M。
- 除非发现没有自旋的M,调度器不会新建或恢复一个M去运行新的G。
- 新建或恢复一个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_
并开始执行。
相关过程:
调度时发现当前M与某个G锁定了。调度器就会调用
stoplockedm
函数停止当前M。stoplockedm
函数会先解除当前M与本地P的关联,并通过handoffp
函数将这个P转手给其他M。hanfoffp
函数会判断这个P是否有继续工作的必要,如果有,就调用startm
函数唤醒一个M与该P关联,如无必要,就直接将该P放入空闲P列表。一旦P被转手,stoplockedm
函数就会停止当前M的执行,并等待被唤醒。当调度器为当前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。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列表。在调度过程中,如果有串行运行时任务等待执行,
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()
如果经历一轮调度后任然找不到一个可运行的G给当前M执行,那么调度程序会调用
stopm
函数停止当前M。也就是说此时已经没有多余的工作可做了,理应停掉一些M以节约资源。所有调用因调用
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)
- 首先注意,
forcegcperiod
是定义在sysmon
函数外边的。它的含义是强制执行GC的时间间隔,默认是2分钟。 scavengelimit
表示清扫堆的时间间隔,默认是5分钟。- 如果我们设置环境变量
GODEBUG
,其值包含scavenge=1
。接下来的if
就会执行,那么forcegcperiod
就会缩短为10微秒,scavengelimit
会缩短为20微秒。这相当于开启了调试模式,仅供调试使用,千万不可用于go程序的正式运行。GODEBUG
环境变量中也可以包含多个键值对,中间用逗号隔开。 idle
表示最近已连续有多少次系统监测任务执行但未能成功夺取P。一旦某次执行过程中成功夺取P,其值就会清零。delay
表示系统监测任务具体的睡眠时间,单位为微秒。最大值为10000us,即10ms。
系统监测任务流程:for
循环
开始之前先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)
睡醒之后,如果发现有串行运行时任务等待执行,或所有P都已空闲,也就是没活干了,那么就继续睡。睡之前将调度器的
sysmonwait
字段(希望你还记得它)设置为1,表示系统监测任务已停止。这次睡的时间是forcegcperiod
和scavengelimit
中较小值的一半。也就是说,是强制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 } }
接下来就是抢夺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数=M总数−空闲M数−锁定M数−执行系统监测任务的M数 运 行 中 的 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)
来恢复系统真实的样子。如果当前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
中定义,类型是forcegcstate
。forcegcstate
是一个结构体类型,其中封装了一个G。var forcegc forcegcstate
type forcegcstate struct { lock mutex g *g idle uint32 }
如果距离上一次清扫堆的时间已经超过了清扫堆的时间间隔的一半,也就是
scavengelimit
的二分之一,就会执行清扫堆的工作,清扫堆会把一段时间内未用的堆内存还给操作系统。而这里的一段时间也就是scavengelimit
的值,初始为5分钟。if lastscavenge+scavengelimit/2 < now { mheap_.scavenge(int32(nscavenge), uint64(now), uint64(scavengelimit)) lastscavenge = now nscavenge++ }
如果程序运行之前设置了
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
函数的流程。
在
retake
函数开始处,首先初始化了一个变量n := 0
,它用来记录成功抢夺到P的次数。而最后retake
函数的返回值也是这个n
,所以在sysmon
函数中我们能够用retake
函数的返回值是否大于0来判断抢夺P是否成功。接下来是对全局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
字段为当前时间,记录下此次系统调用的时刻了。抢夺需要根据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)
这里是当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.PtrSize
在sys
包中的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最大数量流程:
首先再检查一遍新值和旧值,如果不合法就引发一个运行时恐慌,并终止该流程。这也是go为变更P的最大数量上的第二道保险,由于有第一道保险,所以这一道保险几乎是不会被触发的。
/* func procresize(nprocs int32) *p { */ old := gomaxprocs if old < 0 || nprocs <= 0 { throw("procresize: invalid arg") }
如果新值比全局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 } }
假设新值为
I
,接下来对全局P列表的前I
个P进行检查和初始化。如果全局P列表中的P的数量不够,就新建相应数量的P,并加入到全局P列表中。新创建的P会处于Pgcstop
状态,表示它还不能使用。假设旧值为
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 }
经过一番清理之后,执行
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) }
最后,程序再次遍历前
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
调度器的相关内容就先说到这里。其中大部分代码都是经过省略的,只留下了主要逻辑。阅读时可以对照源码查看。