Golang Scheduler原理解析
本文主要分析Golang底层对于协程的调度原理,本文与Golang的memory allocation、garbage collection这两个主题是紧密相关的,本文scheduler作为系列的第一篇文章。
文章大体上的思路是这样的:
section1:主要图示和文字介绍scheduler的原理;
section2:主要模型的角度介绍scheduler原理;
section3:从主要调度流程介绍scheduler原理;
section4:分析scheduler与memory allocation、channel、garbage collection关联部分
基于源码 Go SDK 1.11
Section1 Scheduler原理
1.基础知识
Golang支持语言级别的并发,并发的最小逻辑单位叫做goroutine,goroutine就是Go为了实现并发提供的用户态线程,这种用户态线程是运行在内核态线程(OS线程)之上。当我们创建了大量的goroutine并且同时运行在一个或则多个内核态线程上时(内核线程与goroutine是m:n的对应关系),就需要一个调度器来维护管理这些goroutine,确保所有的goroutine都有相对公平的机会使用CPU。
这里再次强调一次,goroutine与内核OS线程的映射关系是M:N,这样多个goroutine就可以在多个内核线程上面运行。goroutine的切换大部分场景下都没有走OS线程的切换所带来的开销,这样整体运行效率相比OS线程的调度会高很多,但是这样带来的问题就是goroutine调度模型的复杂。
2.调度模型
Golang的调度模型主要有几个主要的实体:G、M、P、schedt。
- G:代表一个goroutine实体,它有自己的栈内存,instruction pointer和一些相关信息(比如等待的channel等等),是用于调度器调度的实体。
- M:代表一个真正的内核OS线程,和POSIX里的thread差不多,属于真正执行指令的人。
- P:代表M调度的上下文,可以把它看做一个局部的调度器,调度协程go代码在一个内核线程上跑。P是实现协程与内核线程的N:M映射关系的关键。P的上限是通过系统变量
runtime.GOMAXPROCS (numLogicalProcessors)
来控制的。golang启动时更新这个值,一般不建议修改这个值。P的数量也代表了golang代码执行的并发度,即有多少goroutine可以并行的运行。 - schedt:runtime全局调度时使用的数据结构,这个实体其实只是一个壳,里面主要有M的全局idle队列,P的全局idle队列,一个全局的就绪的G队列以及一个runtime全局调度器级别的锁。当对M或P等做一些非局部调度器的操作时,一般需要先锁住全局调度器。
为了解释清楚这几个实体之间的关系,我们先抽象G、M、P、schedt的关系,主要的workflow如下图所示:
从上图我们可以分析出几个结论:
- 我们通过 go func()来创建一个goroutine;
- 有两个存储goroutine的队列,一个是局部调度器P的
local queue
、一个是全局调度器数据模型schedt
的global queue。新创建的goroutine会先保存在local queue,如果local queue已经满了就会保存在全局的global queue; - goroutine只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的local queue弹出一个Runable状态的goroutine来执行,如果P的local queue为空,就会执行work stealing;
- 一个M调度goroutine执行的过程是一个loop;
- 当M执行某一个goroutine时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;
- 当M系统调用结束时候,这个goroutine会尝试获取一个空闲的P执行,并放入到这个P的local queue。如果获取不到P,那么这个线程M会park它自己(休眠), 加入到空闲线程中,然后这个goroutine会被放入schedt的global queue。
Go运行时会在下面的goroutine被阻塞的情况下运行另外一个goroutine:
- syscall、
- network input、
- channel operations、
- primitives in the sync package。
3.调度核心问题
前面已经大致介绍了scheduler的一些核心调度原理,介绍的都是比较抽象的内容。听下来还有几个疑问需要分析,主要通过后面的源码来进行细致分析。
Question1:如果一个goroutine一直占有CPU又不会有阻塞或则主动让出CPU的调度,scheduler怎么做抢占式调度让出CPU?
Answer1:有一个sysmon线程做抢占式调度,当一个goroutine占用CPU超过10ms之后,调度器会根据实际情况提供不保证的协程切换机制,具体细节见后面源码分析。
Question2:我们知道P的上限是系统启动时候设定的并且一般不会更改,那么内核线程M上限是多少?遇到需要新的M时候是选取IDEL的M还是创建新的M,整体策略是怎样的?
Answer2:在golang系统启动时候会设置内核线程上限是10000,这里先解释一下,M的数量与P的数量和G的数量没有直接关系,实际情况要看调度器的执行情况。
至于具体的策略见后面的源码分析。
Question3:P、M、G的状态机运转,主要是协程对象G。
Answer3:状态机见后面的源码分析
Question4:每一个协程goroutine的栈空间是保存在哪里的?P、M、G分别维护的与内存有关的数据有哪些?
Answer4:golang scheduler use thrid-level cache,each goroutine stack space is applied in heap。
Question5:当syscall、网络IO、channel时,如果这些阻塞返回了,对应的G会被保存在哪个地方?global Queue或则是local queue? 为什么?系统初始化时候的G会被保存在哪里?为什么?
Answer5:唤醒的M首先会尝试获取一个空闲P,然后将G放到P的local queue,如果获取失败,就放进 global queue,然后M自己放进the M idle 列表。
Notice:scheduler的源码主要在两个文件:
- runtime/runtime2.go 主要是实体G、M、P、schedt的数据模型
- runtime/proc.go 主要是调度实现的逻辑部分。
Section2 主要模型的源码分析
这一部分主要结合源码进行分析,主要分为两个部分,一部分介绍主要模型G、M、P、schedt的职责、维护的数据域以及它们的联系。
2.1 实体M
实体M在模型上等同于系统内核OS线程,M运行的go代码类型有两种:
- goroutine代码, M运行go代码需要一个实体P进行局部调度;
- 原生代码, 例如阻塞的syscall, M运行原生代码不需要P
M会从runqueue(local or global)中抽取G并运行,如果G运行完毕或则G进入了睡眠态,就会从runqueue中取出下一个runnable状态的G运行, 循环调度。
G有时会执行一些阻塞调用(syscall),这时M会释放持有的P并进入阻塞态,其他的M会取得这个idel状态的P并继续运行队列中的G。Golang需要保证有足够的M可以运行G, 不让CPU闲着, 也需要保证M的数量不能过多。通常创建一个M的原因是由于没有足够的M来关联P并运行其中可运行的G。而且运行时系统执行系统监控的时候,或者GC的时候也会创建M。
M结构体定义在runtime2.go如下:
type m struct {
/*
1. 所有调用栈的Goroutine,这是一个比较特殊的Goroutine。
2. 普通的Goroutine栈是在Heap分配的可增长的stack,而g0的stack是M对应的线程栈。
3. 所有与调度相关的代码,都会先切换到g0的栈再执行。
*/
g0 *g // goroutine with scheduling stack
morebuf gobuf // gobuf arg to morestack
divmod uint32 // div/mod denominator for arm - known to liblink
// Fields not known to debuggers.
procid uint64 // for debuggers, but offset not hard-coded
gsignal *g // signal-handling g
goSigStack gsignalStack // Go-allocated signal handling stack
sigmask sigset // storage for saved signal mask
tls [6]uintptr // thread-local storage (for x86 extern register)
// 表示M的起始函数。其实就是我们 go 语句携带的那个函数。
mstartfn func()
// M中当前运行的goroutine
curg *g // current running goroutine
caughtsig guintptr // goroutine running during fatal signal
// 与M绑定的P,如果为nil表示空闲
p puintptr // attached p for executing go code (nil if not executing go code)
// 用于暂存于当前M有潜在关联的P。 (预联)当M重新启动时,即用预联的这个P做关联啦
nextp puintptr
id int64
mallocing int32
throwing int32
// 当前m是否关闭抢占式调度
preemptoff string // if != "", keep curg running on this m
/** */
locks int32
dying int32
profilehz int32
// 不为0表示此m在做帮忙gc。helpgc等于n只是一个编号
helpgc int32
// 自旋状态,表示当前M是否正在自旋寻找G。在寻找过程中M处于自旋状态。
spinning bool // m is out of work and is actively looking for work
blocked bool // m is blocked on a note
inwb bool // m is executing a write barrier
newSigstack bool // minit on C thread called sigaltstack
printlock int8
incgo bool // m is executing a cgo call
freeWait uint32 // if == 0, safe to free g0 and delete m (atomic)
fastrand [2]uint32
needextram bool
traceback uint8
ncgocall uint64 // number of cgo calls in total
ncgo int32 // number of cgo calls currently in progress
cgoCallersUse uint32 // if non-zero, cgoCallers in use temporarily
cgoCallers *cgoCallers // cgo traceback if crashing in cgo call
park note
//这个域用于链接allm
alllink *m // on allm
schedlink muintptr
mcache *mcache
// 表示与当前M锁定的那个G。运行时系统会把 一个M 和一个G锁定,一旦锁定就只能双方相互作用,不接受第三者。
lockedg guintptr
createstack [32]uintptr // stack that created this thread.
lockedExt uint32 // tracking for external LockOSThread
lockedInt uint32 // tracking for internal lockOSThread
nextwaitm muintptr // next m waiting for lock
waitunlockf unsafe.Pointer // todo go func(*g, unsafe.pointer) bool
waitlock unsafe.Pointer
waittraceev byte
waittraceskip int
startingtrace bool
syscalltick uint32
thread uintptr // thread handle
freelink *m // on sched.freem
// these are here because they are too large to be on the stack
// of low-level NOSPLIT functions.
libcall libcall
libcallpc uintptr // for cpu profiler
libcallsp uintptr
libcallg guintptr
syscall libcall // stores syscall parameters on windows
vdsoSP uintptr // SP for traceback while in VDSO call (0 if not in call)
vdsoPC uintptr // PC for traceback while in VDSO call
mOS
}
上面字段很多,核心的主要是以下几个字段:
g0 *g // goroutine with scheduling stack,也是运行局部调度器的g
mstartfn func()
curg *g // current running goroutine
p puintptr // attached p for executing go code (nil if not executing go code)
nextp puintptr
helpgc int32
spinning bool // m is out of work and is actively looking for work
alllink *m // on allm
lockedg guintptr
这些字段主要功能如下:
- g0: Golang runtime系统在线程创建的时候创建的,g0的栈使用的是内核线程的栈,主要用于局部调度器执行调度逻辑时使用的栈,也就是执行调度逻辑时的线程栈。
- mstartfn:表示M的起始函数。其实就是我们 go 关键字后面携带的那个函数。
- curg:存放当前正在运行的G的指针。
- p:指向当前与M关联的那个P。
- nextp:用于暂存于当前M有潜在关联的P。 (预联)当M重新启动时,即用预联的这个P做关联啦
- spinning:自旋状态标志位,表示当前M是否正在寻找G。
- alllink:连接到所有的m链表的一个指针。
- lockedg:表示与当前M锁定的那个G。运行时系统会把 一个M 和一个G锁定,一旦锁定就只能双方相互作用,不接受第三者。
M的状态机比较简单,因为M是golang对内核OS线程的更上一层抽象,所以M也没有专门字段来维护状态,简单来说有一下几种状态:
- 自旋中(spinning): M正在从运行队列获取G, 这时候M会拥有一个P;
- 执行go代码中: M正在执行go代码, 这时候M会拥有一个P;
- 执行原生代码中: M正在执行原生代码或者阻塞的syscall, 这时M并不拥有P;
- 休眠中: M发现无待运行的G时会进入休眠,并添加到空闲M链表中, 这时M并不拥有P。
上面的几种状态中,spinning这个状态非常重要,是否需要唤醒或者创建新的M取决于当前自旋中的M的数量。
M在被创建之初会被加入到全局的M列表 【runtime.allm】。 接着,M的起始函数(mstartfn)和准备关联的P都会被设置。最后,runtime会为M专门创建一个新的内核线程并与之关联。这时候这个新的M就为执行G做好了准备。其中起始函数(mstartfn)仅当runtime要用此M执行系统监控或者垃圾回收等任务的时候才会被设置。【runtime.allm】的作用是runtime在需要的时候会通过它获取到所有的M的信息,同时防止M被gc。
在新的M被创建后会做一些初始化工作。其中包括了对自身所持的栈空间以及信号的初始化。在上述初始化完成后 mstartfn 函数就会被执行 (如果存在的话)。【注意】:如果mstartfn 代表的是系统监控任务的话,那么该M会一直在执行mstartfn 而不会有后续的流程。否则 mstartfn 执行完后,当前M将会与那个准备与之关联的P完成关联。至此,一个并发执行环境才真正完成。之后就是M开始寻找可运行的G并运行之。
runtime管辖的M会在GC任务执行的时候被停止,这时候系统会对M的属性做某些必要的重置并把M放置入全局调度器的空闲M列表。【很重要】因为调度器在需要一个未被使用的M时,运行时系统会先去这个空闲列表获取M。(只有都没有的时候才会创建M)
M本身是无状态的。M是否是空闲态仅根据它是否存在于调度器的空闲M列表 【runtime.sched.midle】 中来判定(注意:空闲列表不是那个全局列表)。
单个Go程序所使用的M的最大数量是可以被设置的。在我们使用命令运行Go程序时候,有一个引导程序先会被启动的。在这个引导程序中会为Go程序的运行建立必要的环境。引导程序对M的数量进行初始化设置,默认最大值是10000【一个Go程序最多可以使用10000个M,即:理想状态下,可以同时有1W个内核线程被同时运行】。可以使用