一、Runtime
1. 为什么需要runtime
- goroutines调度
goroutines是go的执行单元,goroutines如果直接对应操作系统的线程,go在调度goroutines时,势必将像操作系统调度线程一样,需要设置信号掩码、CPU亲和性及cgroup的资源管理等,这些额外的线程操作不是go运行goroutines所需要的,这些操作将消耗大量资源、影响性能。所以go需要runtime来执行goroutines的调度,而不是让操作系统调度线程。 - 垃圾回收
go是需要支持垃圾回收的,执行垃圾回收时,我们需要保证goroutines处于暂停的状态,go的内存才会处于一种一致的状态。
当没有调度器时,线程是由操作系统来调度,但这对于go而言是不可控的,go无法很好的控制线程状态及内存。所以为了GC,go需要自己的调度器。goroutines有go自身调度器控制才能确保内存一致,才能正确地执行GC。
所以要支持协程\线程调度就要有runtime。要支持垃圾回收就要有runtime。
2. 什么是runtime
上面可以分析出runtime所担任的职责:goroutines调度,垃圾回收,当然还提供goroutines的执行环境。
所以这也相当于简要解释了什么是runtime。
go的可执行程序可以分成两个层:用户代码和运行时:
- 运行时提供接口函数供用户代码调用,用来管理goroutines,channels和其他一些内置抽象结构。
-
用户代码对操作系统API的任何调用都会被运行时层截取,以方便调度和垃圾回收。
二、GMP调度模型
- global queue(全局队列):存放等待运行的G。为保证数据竞争问题,需要加锁处理。
- local queue(本地队列):本地队列时无锁的,可以可以提升处理速度。同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
- P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。在任何时候,每个P只能有一个M运行。
- M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。
GMP主要包含4个基本单元:g、m、p、schedt。
- g是协程任务信息单元
- m是实际执行体
- p是本地资源池和本地g任务池
- schedt是全局资源池和全局g任务池
下面我们详细看下这4个基本单元的主要结构:
1. G
G
是Goroutine的缩写,是对Goroutine的抽象。其中包括执行的函数指令及参数;G保存着任务对象,线程上下文切换,现场保护和现场恢复需要的寄存器(SP、IP)等信息。
Goroutine的主要结构(详见runtime/runtime2.go)
type g struct {
stack stack // 描述了当前 Goroutine 的栈内存范围 [stack.lo, stack.hi)
stackguard0 uintptr // 是对比 Go 栈增长的 prologue 的栈指针, 可以用于调度器抢占式调度
stackguard1 uintptr // 是对比 C 栈增长的 prologue 的栈指针
...
_panic *_panic // 最内侧的 panic 结构体
_defer *_defer // 最内侧的延迟函数结构体
m *m // 当前的m
sched gobuf // goroutine切换时,用于保存g的上下文
...
param unsafe.Pointer // 用于传递参数,睡眠时其他goroutine可以设置param,唤醒时该goroutine可以获取
atomicstatus uint32 // Goroutine 的状态
stackLock uint32
goid int64 // goroutine的ID
...
waitsince int64 // g被阻塞的大体时间
preempt bool // 抢占信号
preemptStop bool // 抢占时将状态修改成 `_Gpreempted`
preemptShrink bool // 在同步安全点收缩栈
...
lockedm *m // G被锁定只在这个m上运行
...
}
g
中最主要的当然是sched
了,保存了goroutine的上下文。goroutine切换的时候,不同于线程有OS来负责这部分数据,而是由一个gobuf对象来保存,这样能够更加轻量级,再来看看gobuf的结构(详见runtime/runtime2.go):
type gobuf struct {
// The offsets of sp, pc, and g are known to (hard-coded in) libmach.
//
// ctxt is unusual with respect to GC: it may be a
// heap-allocated funcval, so GC needs to track it, but it
// needs to be set and cleared from assembly, where it's
// difficult to have write barriers. However, ctxt is really a
// saved, live register, and we only ever exchange it between
// the real register and the gobuf. Hence, we treat it as a
// root during stack scanning, which means assembly that saves
// and restores it doesn't need write barriers. It's still
// typed as a pointer so that any other writes from Go get
// write barriers.
sp uintptr // 栈指针
pc uintptr // 程序计数器
g guintptr // 当前gobuf所属的g
ctxt unsafe.Pointer
ret uintptr 系统调用的返回值
lr uintptr
bp uintptr // for framepointer-enabled architectures
}
2. M
M
是一个线程或称为Machine,M是有线程栈的。如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。当指定了线程栈,则M.stack→G.stack,M的PC寄存器指向G提供的函数,然后去执行。
type m struct {
// g0是带有调度栈的goroutine。
// 普通的Goroutine栈是在Heap分配的可增长的stack,而g0的stack是M对应的线程栈。
// 所有调度相关代码,会先切换到该Goroutine的栈再执行。
g0 *g
......
gsignal *g // 处理信号的goroutine
......
tls [6]uintptr // thread-local storage
mstartfn func() //m入口函数
curg *g // 当前运行的goroutine
caughtsig guintptr
p puintptr // 关联p和执行的go代码
nextp puintptr
oldp puintptr // 在执行系统调用之前所附加的p
id int32
mallocing int32 // 状态
......
locks int32 //m的锁
......
spinning bool // m不在执行g,但在积极寻找可执行的g
blocked bool // m是否被阻塞
newSigstack bool
printlock int8
incgo bool // m是否在执行cgo
freeWait uint32 // 如果为0,将安全释放g0并删除m(原子性)。
fastrand uint32
......
ncgocall uint64 // cgo调用的总数
ncgo int32 // 当前cgo调用的数目
......
park note
alllink *m // 用于链接allm
schedlink muintptr
lockedg *g // 锁定g在当前m上执行,而不会切换到其他m
createstack [32]uintptr // thread创建的栈
......
nextwaitm muintptr // 下一个等待的m
......
}
而g0的栈是M对应的线程的栈。所有调度相关的代码,会先切换到该goroutine的栈中再执行。也就是说线程的栈也是用的g实现,而不是使用的OS的。
3. P
P
代表一个处理器,每一个运行的M都必须绑定一个P,就像线程必须在么一个CPU核上执行一样,由P来调度G在M上的运行,P的个数就是GOMAXPROCS(最大256),启动时固定的,一般不修改;M的个数和P的个数不一定一样多(会有休眠的M或者不需要太多的M)(最大10000);每一个P保存着本地G任务队列,也有一个全局G任务队列。P的数据结构:
type p struct {
id int32
status uint32 // 状态,可以为pidle/prunning/...
link puintptr
schedtick uint32 // 每调度一次加1
syscalltick uint32 // 每一次系统调用加1
sysmontick sysmontick
m muintptr // 回链到关联的m
mcache *mcache //当前m的内存缓存,意味着不必为每一个M都配备一块内存,避免了过多的内存消耗。
pcache pageCache
raceprocctx uintptr
......
// goroutine ids的缓存,摊销对runtime-sched.goidgen的访问。
goidcache uint64
goidcacheend uint64
// 可运行的goroutine的队列. 不需要锁即可访问
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr // 下一个运行的g,以高优先级执行 unblock G,提高了一些包的性能。
// 可用的G (status == Gdead, Gdead 表示这个goroutine目前未被使用)
gFree struct {
gList
n int32
}
// sudog 代表等待列表中的一个G,例如在向通道执行发送/接收的G。
sudogcache []*sudog
sudogbuf [128]*sudog
// 堆中mspan对象的缓存
mspancache struct {
// len 被用于不允许写障碍的调用代码路径中
len int
buf [128]*mspan
}
......
palloc persistentAlloc // per-P to avoid mutex
......
}
其中P的状态有Pidle, Prunning, Psyscall, Pgcstop, Pdead;在其内部队列runqhead里面有可运行的goroutine,P优先从内部获取执行的g,这样能够提高效率。
4. schedt
除此之外,还有一个数据结构需要在这里提及,就是schedt,可以看做是一个全局的调度者:
type schedt struct {
// 原子操作访问
goidgen uint64
lastpoll uint64 // 最后一次网络轮询的时间,如果正在轮询则为0
pollUntil uint64 // 当前轮询的睡眠时间
lock mutex
// When increasing nmidle, nmidlelocked, nmsys, or nmfreed, be
// sure to call checkdead().
midle muintptr // idle状态的m
nmidle int32 // idle状态的m个数
nmidlelocked int32 // lockde状态的m个数
mnext int64 // 已经创建的m的数量和下一个m的ID
maxmcount int32 // m允许的最大个数
nmsys int32 // 非locked状态的系统M的数量
nmfreed int64 // 累计可用的M的数量
ngsys uint32 // 系统中goroutine的数目,会自动更新
pidle puintptr // idle的p
npidle uint32
nmspinning uint32 // 寻找可执行gotoutine的m数量
// goroutine全局可运行队列.
runq gQueue
runqsize int32
......
// _Gdead状态G的全局缓存
gFree struct {
lock mutex
stack gList // 有stack的Gs
noStack gList // 没有stack的Gs
n int32
}
// sudog的Central 缓存
sudoglock mutex
sudogcache *sudog
......
// 等待被释放的m的列表
freem *m
......
}
二、GMP调度细节分析
1. schedule
proc.go:3291 findrunnable()
- m是否阻塞?阻塞则sleep此m,其他m在从本地队列尾部获取g执行,执行完成后,继续执行schedule()
- 是否需要执行gc?需要gc则停止spining、释放p、开始sleep,gc完成后会被唤醒,继续执行schedule()
- 若存在gcBgMarkWorker则获取此g,否则偶尔(
1/61
概率)会从全局队列获取g(避免全局对列饥饿),没有则从本地队列获取g。 - 若仍未获取到g,则会进入findrunnable流程,循环地去找g
- 获取到g后,停止spining
- 获取到锁定的g,则将把对应锁定的m调度给当前的p并唤醒m。否则该m将移交p给其他等待中的m并唤醒,该m将sleep
- 再次进入schedule()循环
2. findrunnable
proc.go:2705 findrunnable():
- 是否需要执行gc?需要gc则停止spining、释放p、开始sleep,gc完成后会被唤醒,继续执行findrunnable()
- 从本地队列寻找g,没有则从全局队列寻找g。获取g后,将继续schedule()
- 本地队列、全局队列都没有g,则查看是否存在net或file需要执行。存在则获取g,并继续schedule()
- 没有可执行的g,开始spining,从其他p的本地队列尾部窃取一半的g,并继续scheudle()
- 其他p的本地队列没有g,将会在sleep之前,再次尝试查看全局队列中是否有g
- 仍没有g,则释放p,停止spining并准备sleep
- 检查是否有idle的p,则sleep,直到再次被唤醒
3. spining
线程自旋(spining)是相对于线程阻塞而言的,表象就是循环执行一个指定逻辑(调度逻辑,目的是不停地寻找 G)
缺点: 始终获取不到G时,自旋属于空转,浪费CPU
优点: 降低了 M 的上下文切换成本,提高了性能
GMP中有两个地方会引入自旋:
- 类型1:没有P的M找P挂载,保证一有 P 释放就结合
- 类型2:没有G的M找G运行,保证一有runnable的G就运行
由于P最多只有GOMAXPROCS,所以自旋的M最多只允许GOMAXPROCS个,多了就没有意义了。
同时当有类型1的自旋M存在时,类型2的自旋M就不阻塞,阻塞会释放P,一释放P就马上被类型1的自旋M抢走了,没必要。
有空闲的 P时,在以下3种场景,go调度器会确保至少有一个自旋 M 存在(唤醒或者创建一个 M):
- 新G创建之前
如果有空闲的 P,就意味着新 G 可以被立即执行,即便不在同一个 P 也无妨,所以我们保留一个自旋的 M,就可以保证新 G 很快被运行。
为了执行G,不需要关注在哪个P上运行,这时应该不存在类型 1 的自旋只有类型 2 的自旋 - M进入系统调用(syscall)之前
当 M 进入系统调用,意味着 M 不知道何时可以醒来,那么 M 对应的 P 中剩下的 G 就得有新的 M 来执行,所以我们保留一个自旋的 M 来执行剩下的 G。
为了执行P本地队列中的G,需要和P绑定,这时应该不存在类型 2 的自旋只有类型1的自旋 - M从空闲变成活跃之前
如果M从空闲变成活跃,意味着可能一个处于自旋状态的M进入工作状态了,这时要检查并确保还有一个自旋M存在,以防还有G或者还有P空着的。
4. GMP模式优点
- G分布在全局队列和P本地队列,全局队列依旧是全局锁,但是使用场景明显很少;P 本地队列使用无锁队列,使用原子操作来面对可能的并发场景。(解决了GM模式单一全局互斥锁的问题)
- G 创建时就在 P 的本地队列,可以避免在 P 之间传递(窃取除外),G 对 P 的数据局部性好; 当 G 开始执行了,系统调用返回后 M 会尝试获取可用 P,获取到了的话可以避免在 M 之间传递 。而且优先获取调用阻塞前的 P,所以 G 对 M 数据局部性好,G 对 P 的数据局部性也好。(解决了GM模式中G传递带来的开销问题,以及数据局部性问题)
- 内存 mcache 只存在 P 结构中,就不必为每一个M都配备一块内存了,避免过多的内存消耗。P 最多只有 GOMAXPROCS 个,远小于 M 的个数,所以也不会出现过多的内存消耗。(解决了GM模式中内存消耗大的问题)
- 通过引入自旋,保证任何时候都有处于等待状态的自旋M,避免在等待可用的P和G时频繁的阻塞和唤醒。(解决了GM模式中严重的线程阻塞/解锁问题)
5. syscall阻塞情况下的调度
当M1执行某一个G时候如果发生了syscall或者其他阻塞操作后,M1会阻塞。如果当前P中仍有一些G待执行,runtime会将 Goroutine 的状态更新至 _Gsyscall,将 Goroutine 的P和M暂时分离并更新P的状态到 _Psyscall,表明这个 P 的 G 正在 syscall 中。
当系统调用结束后,会调用退出系统调用的函数 runtime.exitsyscall
为当前 Goroutine 重新分配资源,该函数有两个不同的执行路径:
- 调用
runtime.exitsyscallfast
; - 切换至调度器的 Goroutine 并调用
runtime.exitsyscall0
;
采用较快的路径runtime.exitsyscallfast
优先来重新获取原来的P,能获取到就继续绑回去,这样有利于数据的局部性。runtime.exitsyscallfast
中包含两个不同的分支:
- 如果 Goroutine 的原P处于 _Psyscall 状态,会直接调用
wirep
将 Goroutine 与原P进行关联 - 如果原P不处于 _Psyscall 状态,且调度器中存在闲置的P,会调用 runtime.acquirep 使用闲置的P处理当前 Goroutine;
如果通过runtime.exitsyscallfast
获取不到P,runtime就会采用另一个相对较慢的路径 runtime.exitsyscall0
,将当前 Goroutine 切换至 _Grunnable
状态,并移除线程 M 和当前 Goroutine 的关联,然后执行以下逻辑分支:
- 当我们通过
runtime.pidleget
获取到闲置的处理器时就会在该处理器上执行 Goroutine; -
否则找不到空闲的P,runtime就会把 G 放回 global queue,M 放回到 idle list,等待调度器调度
6. sysmon抢占调度
sysmon 也叫监控线程,它在一个单独的 M 上执行,无需 P 也可以运行,它是一个死循环,每 20us~10ms 循环一次,循环完一次就 sleep 一会。为什么会是一个变动的周期呢,主要是避免空转,如果每次循环都没什么需要做的事,那么 sleep 的时间就会加大。
1. sysmon的主要作用:
- 释放闲置超过 5 分钟的 span 物理内存
- 如果超过 2 分钟没有垃圾回收,强制执行
- 将长时间未处理的 netpoll 添加到全局队列
- 向长时间运行的 G 任务发出
抢占调度
- 收回因 syscall 长时间阻塞的 P
2. 满足什么条件会触发抢占调度
呢?
go 1.13 抢占调度
sysmon发现一个 P 一直处于running状态超过了10ms,将调用preemptone 将 G 的stackguard0=stackPreempt,同时设置sched.gcwaiting=1。被标记后,在该G调用新函数时,通过g.stackguard0判断是否需要栈增长,需要栈增长就会通过morestack()检查执行schedule(),检查到sched.gcwaiting==1时,就会让当前G让出。
G设置了标记位后,也不一定会被抢占。如果G调用的新函数是一个简单的死循环,将无法被抢占。如果G调用的新函数所需栈空间很少也不会被抢占,只有当新函数触发栈空间检查(morestack()), 所需栈大于128字节,才会被抢占。那么这里就带来一些问题,我们看下下面的代码示例:
package main
import "fmt"
func main(n int) {
go func(n int){
for{
n++
fmt.Println(n)
}
}(0)
for{}
}
在go 1.13版本之前,执行上述代码,会阻塞在go func()。原因是当go的GC触发时,会执行STW。而STW会抢占所有的P,让GC来运行。而go func()中是1个简单的死循环,这类操作无法进行newstack、morestack、syscall,所以无法检测stackguard0 == stackpreempt,也就不会执行后续的schedule()让当前G让出,导致阻塞。这种依赖栈增长的方式,不算是真正的抢占式调度。
go 1.14 抢占调度
在go 1.14版本实现了基于信号的抢占式调度
。
- sysmon发现一个 P 一直处于running状态超过了10ms,调用preemptone()方法时,会通过系统调用,向m发送sigPreempt信号。
- m收到信号后,会将信号交给sighandler处理
- sighandler确定信号为sigPreempt以后,调用doSigPreempt函数
- doSigPreempt函数在确认P和G允许抢占,并可以安全地执行抢占后,会向G的执行上下文中注入异步抢占函数asyncPreempt。
- asyncPreempt汇编函数调用后,就会保存G的上下文,并调用schedule()让当前G让出。
我们可以看到基于信号的抢占式调度,不再依赖于栈增长,即使空的for{}没有执行栈增长检测代码,也依然没有阻塞,可以成功实现抢占式调度。
7. netpoller
1. 什么是netpoller
在Go的实现中,期望在用户层面(程序员层面)所有IO都是阻塞调用的,Go的设计思想是程序员使用阻塞式的接口来编写程序,然后通过goroutine+channel来处理并发。因此所有的IO逻辑都是直来直去的,先xx,再xx, 你不再需要回调,不再需要future,要的仅仅是step by step。这对于代码的可读性是很有帮助的。
但是如果在Runtime内部也采用阻塞 I/O 调用,那么物理线程将也处于阻塞状态,导致大量资源的浪费。所以Runtime内部实际使用的是OS提供的非阻塞IO访问模式。那么如何将OS的异步I/O与Golang接口的阻塞I/O互相转换呢?golang内部就通过OS提供的非阻塞IO访问模式、并配合epll/kqueue等IO事件监控机制,通过runtime上做的一层封装,实现将OS的异步I/O与Goroutine的阻塞 I/O互相转换 。这一部分被称之为netpoller
1. goroutine同步调用转OS异步调用
当一个goroutine进行I/O操作时,并且文件描述符数据还没有准备好,经过一系列的调用,最后会进入gopark函数,gopark将当前正在执行的goroutine状态保存起来,然后切换到新的堆栈上执行新的goroutine。由于当前goroutine状态是被保存起来的,因此后面可以被恢复。这样进行I/O操作的goroutine以为一直同步阻塞到现在,其实内部是异步完成的。
2. goroutine什么时候调度回来
在schedule()执行时,findrunnable()中的netpoll()方法被调用后,处于就绪状态的 fd 对应的 G 就会被调度回来。
8. scheduler affinity
goroutine之间使用channel来回通信时,会导致goroutine频繁阻塞,导致其在本地队列会进行频繁地重新排队,导致goroutine存在被重排后有可能会被窃取的风险。
Go 1.5 在 P 中引入了runnext 特殊的一个字段,当一组goroutines在communicate-and-wait模式中被阻塞,但很快就runnable了,便会将runnext分别指向这组goroutines。在当前G运行结束,之后将立即执行runnext对应的G,而不是本地队列中的G。这允许 goroutine 在再次被阻塞之前能够快速运行,提高了一部分性能。
六、goroutine的生命周期
- 流程步骤:
- runtime创建最初的线程m0和goroutine g0,并把2者关联。
- 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化P列表,P的数目优先取环境变量GOMAXPROCS,否则默认是cpu核数。随后把第一个P(便于理解可以叫它p0)与m0进行绑定,这样m0就有他自己的p了,就有条件执行后续的任务g了。
- m0的g0会执行调度任务(runtime.newproc),创建一个g,g指向runtime.main()(还不是我们main包中的main),并放到p的本地队列。这样m0就已经同时具有了任务g和p,什么条件都具备了。
runtime.main(): 启动 sysmon 线程;启动 GC 协程;执行 init,即代码中的各种 init 函数;执行 main.main 函数。
- 启动m0,m0已经绑定了P,会从P的本地队列获取g。
- g拥有栈,m根据g中的栈信息和调度信息设置运行环境
- M运行g
- g退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序。
- M0
M0
是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了。 - G0
G0
是每次启动一个M都会第一个创建的gourtine,G0仅负责调度,即shedule() 函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。 - G0的调度
以下3种场景,G0会执行调度:
- 当前 G 执行完成,G0会执行调度获取下一个G
- 当前 G 阻塞时:系统调用、互斥锁或 chan,G0会执行调度
- 在函数调用期间,如果当前 G 必须扩展其堆栈,G0会执行调度
与常规 G 相反,G0 有一个固定和更大的栈。G0除了负责G的调度,还有以下功能:
- Defer 函数的分配
- GC 收集,比如 STW、扫描 G 的堆栈和标记、清除操作
- 栈扩容,当需要的时候,由 g0 进行扩栈操作
从 g 到 g0 或从 g0 到 g 的切换是相当迅速的,它们只包含少量固定的指令。相反,对于schedule(),执行schedule()需要检查许多资源以便确定下一个要运行的 G。
g0调度具体流程:
- 当前 g 阻塞在 chan 上并切换到 g0:
1、g的PC (程序计数器)和堆栈指针一起保存在内部结构中;
2、将 g0 设置为正在运行的 goroutine;
3、g0 的堆栈替换当前堆栈; - g0 执行schedule(),寻找runnable g
- g0 使用所选的 G 进行切换:
1、PC 和堆栈指针是从G内部结构中获取的;
2、程序跳转到对应的 PC 地址;
References:
http://www.cs.columbia.edu/~aho/cs6998/reports/12-12-11_DeshpandeSponslerWeiss_GO.pdf
https://www.yuque.com/aceld/golang/srxd6d
https://zhuanlan.zhihu.com/p/68299348
https://rakyll.org/scheduler/
https://zhuanlan.zhihu.com/p/27056944
https://www.cnblogs.com/sunsky303/p/11058728.html
https://yizhi.ren/2019/06/03/goscheduler
https://yizhi.ren/2019/06/08/gonetpoller
https://cloud.tencent.com/developer/article/1234360
https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine