引入调度器原因
1. go垃圾回收
垃圾回收需要内存处于一致性状态,需要stop the world,时间点不确定,仅OS调度无法控制。
2. 提高并发性
采用M:N线程模型,每个用户线程对应多个内核空间线程,同时也可以一个内核空间线程对应多个用户空间线程。当一个Goroutine在进行阻塞操作(比如系统调用)时,可以把当前线程中的其他Goroutine移交到其他线程中继续执行, 从而避免了整个程序的阻塞。
3. 更好的管理goroutine
因为引入Goroutine,一个Goroutine既包含要执行的代码,又包含用于执行该代码的栈和PC、SP指针等,对于Goroutine的管理也极为复杂。
4. 栈管理
每个Goroutine都有自己的栈,每个栈大小都不同。如果采用常规方式,以最差情况为每个栈分配足够大的栈空间,会造成内存的极大浪费。为了解决这种问题,gcc引入了Split Stacks技术。创建栈时,只分配一块比较小的内存,如果进行某次函数调用导致栈空间不足时,就会在其他地方分配一块新的栈空间。新的空间不需要和老的栈空间连续。函数调用的参数会拷贝到新的栈空间中,接下来的函数执行都在新栈空间中进行。
4.1 Split Stacks存在问题:
1-hot split
拆分热点
如果堆栈几乎满了,调用将强制分配新的堆栈块。当该调用返回时,将释放新的堆栈块。如果相同的调用在紧密循环中重复发生,则alloc/free的开销会导致很大的开销。
2-栈的伸缩不能一次完成
栈allocation/deallocation
工作永无止境,每次堆栈大小在任一方向通过阈值时,都需要额外的工作。
Goroutine为了解决Splie Stacks的效率问题,使用了连续栈技术(Contiguous stacks)。
- 所有G一开始默认分配最小栈,在G结束时回收栈,在新的G开始时,从回收的栈里获取一个。即栈是可以重复利用的,减少了内存的申请释放。
- 并且Go会记录每个goroutine的栈大小,在下次分配时预先分配足够大的栈。
- go没有每次都计算新的栈大小,只是简单的以指数级方式增长栈大小,一次不行则重复,直到栈大小足够。
5. goroutine抢占
Goroutine调度不像OS线程调度那样有时间片的概念,因此实际抢占机制要弱很多:Go中的抢占实际上是为G设置抢占标记(g.stackguard0=stackPreempt),在后续可以触发调度的时候进行判断。
1. 调度器组成
调度器采用MPG,以及schedt来保存调度器所有状态信息。
go调度器主要有4个重要结构:m
,p
,g
,schedt
。
定义在src\runtime\runtime2.go
中。
1.1 M:代表操作系统线程
一个M就是一个线程,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等信息。
m的最大个数为p的个数。
M的状态:
- spinning bool; M处于自旋中,M当前没有执行代码,拥有P,并等待获取一个G。
1.2 P: M处理G时的上下文。
Processor,它也维护了一个goroutine队列,存储所有需要它来执行的goroutine。一个P最多256个goroutine。
P的数量默认为系统CPU个数。由环境变量GOMAXPROCS
,或者runtime.GOMAXPROCS()
设置。
P与M的关系不固定,当P空闲时,就与M解绑。
有两个设置p个数的触发场景:
- 在进程刚启动时,由
schedinit()
调用procresize()
创建所有的p。 - 运行时,通过
runtime.GOMAXPROCS()
调用procresize()
。
P的状态:
_Pidle
:当M发现无待运行的G时会进入休眠, 这时M拥有的P会变为空闲并加到空闲P链表中。_Prunning
:当M拥有了一个P后, 这个P的状态就会变为运行中, M运行G会使用这个P中的资源_Psyscall
:进入系统调用时,go运行时在系统调用前插代码reentersyscall()
中设置。_Pgcstop
:当gc停止了整个世界(STW)时, P会变为此状态。_Pdead
:当P的数量在运行时改变(调用procresize()
, 且数量减少时多余的P会变为此状态。
1.3 G:实现的核心结构
goroutine,G维护了goroutine需要的栈、程序计数器以及它所在的M等信息。
G一般分配给当前的P,当前P的可执行队列满了以后,G被放入全局队列,同时将本地队列一半移到全局队列。
当P的队列空了的时候,会从全局队列偷取一批(最多32个)到本地队列。
goroutine状态:
- _Gidle=0, 刚创建的G,还未初始化。
- _Grunnable=1,处于run队列中(本地、全局),还没拥有栈。
- _Grunning=2,可以执行代码了,G拥有栈,不在run队列中,并且被分配给了M,P。
- _Gsyscall=3,G正在执行一个系统调用,没有执行用户代码,拥有栈,不在run队列,被分配了M。
- _Gwaiting=4,G被runtime阻塞,没有执行用户代码,没有在run队列,不拥有栈。
- _Gmoribund_unused=5,该状态未使用。
- _Gdead=6,G未使用,有三种情况处于该状态:1.刚退出。2.处于free列表。3.刚把初始化。没有执行用户代码。
- _Genqueue_unused=7,该状态未使用。
- _Gcopystack=8,G的栈正在被移动,没有执行用户代码。
- _Gscan=0x1000,
_Gscanrunnable = _Gscan + _Grunnable // 0x1001
_Gscanrunning = _Gscan + _Grunning // 0x1002
_Gscansyscall = _Gscan + _Gsyscall // 0x1003
_Gscanwaiting = _Gscan + _Gwaiting // 0x1004
2. MPG关系
2.1. G与P
G放在P上才可以被运行。有三种方式获取一个G。
- 本地队列
P.runq
,通过runqget()
获取。 - 全局队列
sched.runq
,通过globrunqget()
获取。 - 从其他P上偷取,通过
runqsteal()
获取。
当一个G被创建出来,或者变为可执行状态时,就把他放到P的可执行队列中。当一个G执行结束时,P会从队列中把该G取出;
通过P,避免了每次都从全局队列中获取G,避免了全局锁。
2.2. P与M
P要与M关联才可以运行。通过acquirep()
将P与M关联。通过releasep()
将P与M解绑。
3. 需要绑定在M上才能运行;
2.3 m0,g0
go中有特殊的M和G, 它们是m0和g0。m0负责执行初始化操作和启动第一个G。g0是仅用于负责调度的G, g0不指向任何可执行的函数, 每个m都会有一个自己的g0。
var (
m0 m
g0 g
raceprocctx0 uintptr
)
3. m,p,g结构定义
3.1. m
主要成员如下:
g0 *g
,调度和执行系统调用都会切换到g0栈去执行。curg *g
,当前正在执行的goroutinep puintptr
,当前m附着的p,用来执行goroutine。oldp puintptr
,执行系统调用前附着的p。nextp puintptr
: 唤醒M时, M会拥有这个Ppark note
,线程唤醒用的key。spinning bool
,标识M处于自旋状态
3.2 p
主要成员:
status uint32
,p状态m muintptr
,附着在的mrunq [256]guintptr
,待执行g对象列表,最多256个。gFree
,可复用g对象列表。link puintptr
, 下一个P.gcBgMarkWorker
,后台GC的worker函数, 如果它存在M会优先执行它gcw gcWork
,
3.3. g
主要成员:
stack
,栈空间sched gobuf
,g对象的调度数据,当goroutine被中断时,当前的状态数据保存在这里。atomicstatus uint32
,g的状态preempt bool
,抢占信号
4. 调度器schedule()首次调用
go进程启动后,
- 会先进行调度器的初始化schedinit()(见12-go进程启动过程)。
- 然后调用
runtime·newproc
创建一个goroutine
,用于执行runtime.main
。 - 然后调用
runtime·mstart
首次调用schedule()
,执行runtime.main
goroutine. runtime.main
中创建一个线程,执行sysmon
,sysmon
会通过netpoll
获取网络事件。runtime.main
执行main.main
.
5. 调度器调度时机
goroutine的调度通过schedule()
进行,触发schedule()
调用的时机有:
-
go程序主动触发
代码中主动调用runtime.Gosched()
。 -
系统调用
代码中发起系统调用时,go会在系统调用前后插入代码。
前插代码:主要设置当前p进入_Psyscall
状态,唤醒sysmon
,触发gc。将当前p与m解绑。
后插代码:主要将之前的p与m关联(如果p还没被别的m关联),关联后直接执行。如果之前的P已经被别的m关联,则获取一个空闲的P,获取失败,则调用调度器schedule()
。 -
sysmon对p抢占
sysmon通过retake()
来发起对p或者g的抢占,这里只是打上标记,真正的抢占在发生函数调用时,通过newstack()
进行:- 对G抢占
P在同一个G上运行时间超过10MS。就标记G为需要抢占,g.preempt=true
和g.stackguard0 = stackPreempt
。 - 对P抢占
P处于系统调用状态,判断是否需要发起抢占。当超过1个sysmon调度周期(至少20us),当P上有待执行任务,或者发起系统调用时间超过10MS,就会对P发起抢占(handoffp()
)。handoffp()
主要功能就是判断是否需要将P交给另外一个m执行。P上有待执行任务,或者全局队列上有待执行任务,就将P交个另外一个m执行。如果P没有任务可执行,就将P放入空闲列表。
- 对G抢占
-
sysmon对异步事件处理
sysmon会每10MS进行一次netpoll操作,有事件触发时,调用injectglist()
将G放入全局待执行列表,如果此时有P处于空闲状态,则针对每个P执行一次startm(nil,false)
获取一个空闲的P,如果有空闲的M则唤醒,没有则新建一个M执行P。 -
select触发gopark()
当对chan进行select操作时,如果chan上没有数据,则会调用gopark()
,gopark()
中,如果G允许被抢占,则设置抢占标记,并调用park_m()
将G与M解除绑定,然后触发调度器调用,schedule()
。
6. 调度器实现schedule()
- 获取当前的G。
- 判断是否处于GC中,是则循环等待GC结束。
- 调用
findRunnableGCWorker()
,判断是否可以进行当前p上的gc。 - 没有可以进行的gc,则调用
globrunqget()
,每60次schedule()调用,就优先从全局队列中取可执行的G;防止全局列表中的G得不到执行。 - 调用
findrunnable()
,获取一个可以运行的G。此过程是阻塞的,直到获取到一个可以执行的G。 - 调用
execute()
,执行G。 execute()
调用gogo()
(src\syscall\asm_linux_amd64.s
),gogo()
从sched结构恢复G上次被调度器暂停时的寄存器现场(SP、PC等),然后继续执行。
// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
// 1. 获取当前的g。
_g_ := getg()
top:
// 2. 判断是否处于gc等待状态。处于等待状态,则通知m线程sleep,并循环判断等待直到gc结束。
if sched.gcwaiting != 0 {
gcstopm()
goto top
}
var gp *g
var inheritTime bool
// 3. 查找traceReader goroutine。找到则设置状态为```_Gwaiting```->```_Grunnable```。
if trace.enabled || trace.shutdown {
gp = traceReader()
if gp != nil {
casgstatus(gp, _Gwaiting, _Grunnable)
traceGoUnpark(gp, 0)
}
}
// 4.没有traceReader,则查找当前p的后台mark goroutine。
if gp == nil && gcBlackenEnabled != 0 {
gp = gcController.findRunnableGCWorker(_g_.m.p.ptr())
}
// 5. 没有traceReader,也没有gc后台mark goroutine,则从全局待执行队列获取一个g。
// 优先以60%的概率从全局队列获取。
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)
}
}
// 6. 没有从全局队列获取到g,则从本地队列获取。
if gp == nil {
gp, inheritTime = runqget(_g_.m.p.ptr())
...
}
// 7. 调用findrunnable(),获取一个可以运行的G。次过程是阻塞的,直到获取到一个可以执行的G。
if gp == nil {
gp, inheritTime = findrunnable() // blocks until work is available
}
// This thread is going to run a goroutine and is not spinning anymore,
// so if it was marked as spinning we need to reset it now and potentially
// start a new spinning M.
if _g_.m.spinning {
resetspinning()
}
if sched.disable.user && !schedEnabled(gp) {
// Scheduling of this goroutine is disabled. Put it on
// the list of pending runnable goroutines for when we
// re-enable user scheduling and look again.
lock(&sched.lock)
if schedEnabled(gp) {
// Something re-enabled scheduling while we
// were acquiring the lock.
unlock(&sched.lock)
} else {
sched.disable.runnable.pushBack(gp)
sched.disable.n++
unlock(&sched.lock)
goto top
}
}
if gp.lockedm != 0 {
// Hands off own p to the locked m,
// then blocks waiting for a new p.
startlockedm(gp)
goto top
}
// 8. 执行G。
execute(gp, inheritTime)
}
6.2. findrunnable()
findrunnable()逻辑比较复杂。主要分为两个阶段top/stop。这里会一直阻塞直到找到可以运行的G。
6.2.1 top阶段
- 循环等待gc结束。
- 调用
runqget()
,从当前P的队列中取G,找到就返回。 - 调用
globrunqget()
,从全局队列中取可执行的G,找到就返回。 - 调用
netpoll(非阻塞调用)
,判断是否有异步调用结束的G,找到就返回。
如果发生了异步事件,则调用injectglist(),间接startm()调用,唤醒空闲M,没有空闲的就创建一个新的M线程。 - 调用
runqsteal()
,从其他P的队列中偷取。
6.2.2 stop阶段
如果top阶段没有获取到可以执行的G,则尝试执行GC,如果不需要GC,则尝试从全局队列、异步事件获取G。
- 如果处于垃圾回收标记阶段,就进行垃圾回收的标记工作,返回该G。
- 调用
globrunqget()
,从全局队列中取可执行的G,找到就返回。
此时如果依然没有找到可用的G,则说明当前P处于空闲状态,将其与M解绑,放入空闲列表。 - 调用
netpoll(阻塞调用)
,获取异步调用结束的G。
由第二步知道,此时当前P已经与M解绑,需要重新找到一个空闲的P,找到就返回第一个G,如果没有找到,则通知当前M sleep。并重回top阶段。
这里如果有异步事件发生,则调用injectglist(),间接startm()调用,获取一个空闲的M唤醒,如果没有空闲的M,则新建一个M线程。
// Finds a runnable goroutine to execute.
// Tries to steal from other P's, get g from global queue, poll network.
func findrunnable() (gp *g, inheritTime bool) {
_g_ := getg()
top:
_p_ := _g_.m.p.ptr()
// 1. 持续等待gc结束。
if sched.gcwaiting != 0 {
gcstopm()
goto top
}
// 2. 尝试从本地等待队列获取一个g。找到则返回该g。
if gp, inheritTime := runqget(_p_); gp != nil {
return gp, inheritTime
}
// 3. 尝试从全局队列获取一个g。全局需要加锁。找到就返回该g。
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(_p_, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false
}
}
// 4. 对网络进行poll,判断是否有网络事件发生。如果有则设置g状态```_Gwaiting```->```_Grunnable```。返回第一个g。
// Poll network.
// This netpoll is only an optimization before we resort to stealing.
// We can safely skip it if there are no waiters or a thread is blocked
// in netpoll already. If there is any kind of logical race with that
// blocked thread (e.g. it has already returned from netpoll, but does
// not set lastpoll yet), this thread will do blocking netpoll below
// anyway.
if netpollinited() && atomic.Load(&netpollWaiters) > 0 && atomic.Load64(&sched.lastpoll) != 0 {
if list := netpoll(false); !list.empty() { // non-blocking
gp := list.pop()
injectglist(&list)
casgstatus(gp, _Gwaiting, _Grunnable)
if trace.enabled {
traceGoUnpark(gp, 0)
}
return gp, false
}
}
// 5. 没有网络事件发生,则从其他p偷取一个g。如果此时空闲的p达到```GOMAXPROCS-1```,进入stop阶段。
// Steal work from other P's.
procs := uint32(gomaxprocs)
if atomic.Load(&sched.npidle) == procs-1 {
// Either GOMAXPROCS=1 or everybody, except for us, is idle already.
// New work can appear from returning syscall/cgocall, network or timers.
// Neither of that submits to local run queues, so no point in stealing.
goto stop
}
// 6. 如果spinning状态的M数量 >= 繁忙状态P数量,就阻塞,避免CPU过于繁忙。
// If number of spinning M's >= number of busy P's, block.
// This is necessary to prevent excessive CPU consumption
// when GOMAXPROCS>>1 but the program parallelism is low.
if !_g_.m.spinning && 2*atomic.Load(&sched.nmspinning) >= procs-atomic.Load(&sched.npidle) {
goto stop
}
// 7. 如果m不是出于```spinning```状态,则设置为出于```spinning```,并将调度器出于spinning计数器加1。
if !_g_.m.spinning {
_g_.m.spinning = true
atomic.Xadd(&sched.nmspinning, 1)
}
// 8. 随机从其他p中偷取一个g,会尝试4次。偷到则返回。
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
}
}
}
stop:
// 9. 到目前为止还没找到g,判断是否可以进行gc扫描。
// 如果p上的gc扫描work可以继续工作,则设置该gc g状态```_Gwaiting```->```_Grunnable```。返回该g。
// We have nothing to do. If we're in the GC mark phase, can
// safely scan and blacken objects, and have work to do, run
// idle-time marking rather than give up the P.
if gcBlackenEnabled != 0 && _p_.gcBgMarkWorker != 0 && gcMarkWorkAvailable(_p_) {
_p_.gcMarkWorkerMode = gcMarkWorkerIdleMode
gp := _p_.gcBgMarkWorker.ptr()
casgstatus(gp, _Gwaiting, _Grunnable)
if trace.enabled {
traceGoUnpark(gp, 0)
}
return gp, false
}
// wasm only:
// If a callback returned and no other goroutine is awake,
// then pause execution until a callback was triggered.
if beforeIdle() {
// At least one goroutine got woken.
goto top
}
// Before we drop our P, make a snapshot of the allp slice,
// which can change underfoot once we no longer block
// safe-points. We don't need to snapshot the contents because
// everything up to cap(allp) is immutable.
allpSnapshot := allp
// return P and block
lock(&sched.lock)
if sched.gcwaiting != 0 || _p_.runSafePointFn != 0 {
unlock(&sched.lock)
goto top
}
// 10. 再次尝试从全局队列获取g,如果全局可运行队列不为空,则从全局队列获取一个g。
if sched.runqsize != 0 {
gp := globrunqget(_p_, 0)
unlock(&sched.lock)
return gp, false
}
// 11. 全局队列为空,说明当前p处于空闲状态,则将p与m解绑。
if releasep() != _p_ {
throw("findrunnable: wrong p")
}
pidleput(_p_)
unlock(&sched.lock)
// Delicate dance: thread transitions from spinning to non-spinning state,
// potentially concurrently with submission of new goroutines. We must
// drop nmspinning first and then check all per-P queues again (with
// #StoreLoad memory barrier in between). If we do it the other way around,
// another thread can submit a goroutine after we've checked all run queues
// but before we drop nmspinning; as the result nobody will unpark a thread
// to run the goroutine.
// If we discover new work below, we need to restore m.spinning as a signal
// for resetspinning to unpark a new worker thread (because there can be more
// than one starving goroutine). However, if after discovering new work
// we also observe no idle Ps, it is OK to just park the current thread:
// the system is fully loaded so no spinning threads are required.
// Also see "Worker thread parking/unparking" comment at the top of the file.
wasSpinning := _g_.m.spinning
if _g_.m.spinning {
_g_.m.spinning = false
if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 {
throw("findrunnable: negative nmspinning")
}
}
// 12. 再次检查全局p列表,找到待运行队列不为空的P,同时找到一个空闲的P,将其与当前M关联。回到top阶段尝试让其执行该G。
// check all runqueues once again
for _, _p_ := range allpSnapshot {
if !runqempty(_p_) {
lock(&sched.lock)
_p_ = pidleget()
unlock(&sched.lock)
if _p_ != nil {
acquirep(_p_)
if wasSpinning {
_g_.m.spinning = true
atomic.Xadd(&sched.nmspinning, 1)
}
goto top
}
break
}
}
// 13. 没有找到可以执行的G,则再次尝试进行GC。
// Check for idle-priority GC work again.
if gcBlackenEnabled != 0 && gcMarkWorkAvailable(nil) {
lock(&sched.lock)
_p_ = pidleget()
if _p_ != nil && _p_.gcBgMarkWorker == 0 {
pidleput(_p_)
_p_ = nil
}
unlock(&sched.lock)
if _p_ != nil {
acquirep(_p_)
if wasSpinning {
_g_.m.spinning = true
atomic.Xadd(&sched.nmspinning, 1)
}
// Go back to idle GC check.
goto stop
}
}
// 14. 阻塞调用netpoll()。如果有事件发生,并且获取到一个空闲的P,将其与当前M关联,返回第一个G。
// poll network
if netpollinited() && atomic.Load(&netpollWaiters) > 0 && atomic.Xchg64(&sched.lastpoll, 0) != 0 {
if _g_.m.p != 0 {
throw("findrunnable: netpoll with p")
}
if _g_.m.spinning {
throw("findrunnable: netpoll with spinning")
}
list := netpoll(true) // block until new work is available
atomic.Store64(&sched.lastpoll, uint64(nanotime()))
if !list.empty() {
lock(&sched.lock)
_p_ = pidleget()
unlock(&sched.lock)
if _p_ != nil {
acquirep(_p_)
gp := list.pop()
// 15. 设置可执行G状态```_Gwaiting```->````_Grunnable```。都放入全局待执行队列。
// 根据空闲P个数,调用startm()
injectglist(&list)
casgstatus(gp, _Gwaiting, _Grunnable)
if trace.enabled {
traceGoUnpark(gp, 0)
}
return gp, false
}
injectglist(&list)
}
}
// 16. 将m与p解绑,通知m线程sleep。
stopm()
// 17. 继续循环,找到可以执行的G。
goto top
}
7. gcstopm()
- 将p与m解绑。
- 设置p的状态为
_Pgcstop
。 - 通知m线程sleep。
// Stops the current m for stopTheWorld.
// Returns when the world is restarted.
func gcstopm() {
_g_ := getg()
if sched.gcwaiting == 0 {
throw("gcstopm: not waiting for gc")
}
if _g_.m.spinning {
_g_.m.spinning = false
// OK to just drop nmspinning here,
// startTheWorld will unpark threads as necessary.
if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 {
throw("gcstopm: negative nmspinning")
}
}
// 将p与m解绑
_p_ := releasep()
lock(&sched.lock)
// 设置p的状态为_Pgcstop.
_p_.status = _Pgcstop
sched.stopwait--
if sched.stopwait == 0 {
notewakeup(&sched.stopnote)
}
unlock(&sched.lock)
// 通知m线程sleep。
stopm()
}
stopm()
- 将调度器当前正在等待工作指针设置为当前m。
sched.midle = m
。给等待计数器加1.sched.nmidle++
. - 通知m线程sleep。
- 将m.nextp与m关联。同时置m的nextp为0.
// Stops execution of the current m until new work is available.
// Returns with acquired P.
func stopm() {
_g_ := getg()
if _g_.m.locks != 0 {
throw("stopm holding locks")
}
if _g_.m.p != 0 {
throw("stopm holding p")
}
if _g_.m.spinning {
throw("stopm spinning")
}
lock(&sched.lock)
mput(_g_.m)
unlock(&sched.lock)
notesleep(&_g_.m.park)
noteclear(&_g_.m.park)
acquirep(_g_.m.nextp.ptr())
_g_.m.nextp = 0
}