Golang-Scheduler原理解析
本文主要分析Golang裡面對於協程的排程原理,本文與Golang的memory allocation、channel、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語言為了實現併發提供的使用者態執行緒,這種使用者態執行緒只能執行在核心態執行緒之上。當我們建立了很多很多的goroutine同時執行在一個或則多個核心態執行緒上時(m:n的對應關係),就需要一個排程器來維護管理這些goroutine,確保所有的goroutine都能使用CPU,並且保證相對的公平性。
使用者執行緒與核心執行緒的對映關係是M:N,這樣多個goroutine就可以在多個核心執行緒上面執行,這樣整體執行效率肯定是最高的,但是這樣帶來的問題就是排程模型的複雜。
2. 排程模型
Golang的排程模型主要有幾個主要的實體:G、M、P、Schedt。
- G:代表一個goroutine,它有自己的棧記憶體,instruction pointer和其餘資訊(等待的channel等等),用於排程。
- M:代表一個真正的核心OS執行緒,和POSIX裡的thread差不多,真正幹活的人。
- P:代表M排程的上下文,可以把它看做一個區域性的排程器,使go程式碼在一個執行緒上跑,它是實現N:M對映的關鍵。P的上限是通過
runtime.GOMAXPROCS (numLogicalProcessors)
來控制的,而且是系統啟動時固定的,一般不建議修改這個值,P的數量也代表了併發度,即有多少goroutine可以同時執行。 - schedt:全域性排程使用的資料結構,這個實體只是一個殼,裡面主要有M的idle佇列,P的idle佇列,一個全域性的就緒的G佇列以及一個排程器級別的鎖,當對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彈出一個goroutine來執行,如果P的local queue空了,就會執行work stealing。
- 一個M排程goroutine執行是一個loop。
- 當M執行某一個goroutine時候如果發生了syscall或則其餘阻塞操作,阻塞的系統呼叫會中斷(intercepted),如果當前有一些G在執行,執行時會把這個執行緒M從P中摘除(detach),然後再建立一個新的作業系統的執行緒(如果沒有空閒的執行緒可用的話)來服務於這個P。
- 當系統呼叫結束時候,這個goroutine會嘗試獲取一個空閒的P執行,並放入到這個P的本地執行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?
Question2:我們知道P的上限是系統啟動時候設定的並且一般不會更改,那麼核心執行緒M上限是多少?遇到需要新的M時候是選取IDEL的M還是建立新的M,整體策略是怎樣的?
Question3:P、M、G的狀態機運轉,主要是協程物件G。
Question4:每一個協程goroutine的棧空間是儲存在哪裡的?P、M、G分別維護的與記憶體有關的資料有哪些?
Question5:當syscall、網路IO、channel時,如果這些阻塞返回了,對應的G會被儲存在哪個地方?global Queue或則是local queue? 為什麼?系統初始化時候的G會被儲存在哪裡?為什麼?
Notice:scheduler的原始碼主要在兩個檔案:
- runtime/runtime2.go 主要是實體G、M、P、schedt的資料模型
- runtime/proc.go 主要是排程實現的邏輯部分。
Section2 主要模型的原始碼分析
這一部分主要結合原始碼進行分析,主要分為兩個部分,一部分介紹主要模型G、M、P、schedt的職責、維護的資料域以及它們的聯絡。
M
M等同於系統核心OS執行緒,M執行go程式碼有兩種:
- goroutine程式碼, M執行go程式碼需要一個P
- 原生程式碼, 例如阻塞的syscall, M執行原生程式碼不需要P
M會從runqueue(local or global)中抽取G並執行,如果G執行完畢或則進入睡眠態,就會從runqueue中取出下一個G執行, 周而復始。
難以避免G有時候會執行一些阻塞呼叫(syscall),這時M會釋放持有的P並進入阻塞態,其他的M會取得這個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. 所有排程相關程式碼,會先切換到該Goroutine的棧再執行。
*/
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
// 與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
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
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系統在啟動之初建立的,用於執行一些執行時任務。
- mstartfn:表示M的起始函式。其實就是我們 go 語句攜帶的那個函式啦。
- curg:存放當前正在執行的G的指標。
- p:指向當前與M關聯的那個P。
- nextp:用於暫存於當前M有潛在關聯的P。 (預聯)當M重新啟動時,即用預聯的這個P做關聯啦
- spinning:表示當前M是否正在尋找G。在尋找過程中M處於自旋狀態。
- alllink:連線到所有的m連結串列的一個指標。
- lockedg:表示與當前M鎖定的那個G。執行時系統會把 一個M 和一個G鎖定,一旦鎖定就只能雙方相互作用,不接受第三者。
M的狀態機比較簡單,因為是對核心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(p)都會被設定。最後,執行時系統會為M專門建立一個新的核心執行緒並與之關聯。這時候這個新的M就為執行G做好了準備。其中起始函式(mstartfn)僅當執行時系統要用此M執行系統監控或者垃圾回收等任務的時候才會被設定。全域性M列表的作用是執行時系統在需要的時候會通過它獲取到所有的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的數量進行初始化設定,預設是 最大值 1W 【即是說,一個Go程式最多可以使用1W個M,即:理想狀態下,可以同時有1W個核心執行緒被同時執行】。使用 runtime/debug.SetMaxThreads() 函式設定。
P(processor)
P是一個抽象的概念,並不代表一個具體的實體,抽象表示M執行G所需要的資源。P並不代表CPU核心數,而是表示執行go程式碼的併發度。有一點需要注意的是,執行原聲程式碼並不受P數量的限制。
同一時間只有一個執行緒(M)可以擁有P, P中的資料都是鎖自由(lock free)的, 讀寫這些資料的效率會非常的高。
P是使G能夠在M中執行的關鍵。Go的runtime適當地讓P與不同的M建立或者斷開聯絡,以使得P中的那些可執行的G能夠在需要的時候及時獲得執行時機。
P結構體定義在runtime2.go如下:
type p struct {
lock mutex
id int32
// 當前p的狀態
status uint32 // one of pidle/prunning/...
// 連結
link puintptr
schedtick uint32 // incremented on every scheduler call
syscalltick uint32 // incremented on every system call
sysmontick sysmontick // last tick observed by sysmon
// p反向連結到關聯的m(空閒時為nil)
m muintptr // back-link to associated m (nil if idle)
mcache *mcache
racectx uintptr
deferpool [5][]*_defer // pool of available defer structs of different sizes (see panic.go)
deferpoolbuf [5][32]*_defer
// Cache of goroutine ids, amortizes accesses to runtime·sched.goidgen.
goidcache uint64
goidcacheend uint64
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32
runqtail uint32
runq [256]guintptr
// runnext, if non-nil, is a runnable G that was ready'd by
// the current G and should be run next instead of what's in
// runq if there's time remaining in the running G's time
// slice. It will inherit the time left in the current time
// slice. If a set of goroutines is locked in a
// communicate-and-wait pattern, this schedules that set as a
// unit and eliminates the (potentially large) scheduling
// latency that otherwise arises from adding the ready'd
// goroutines to the end of the run queue.
runnext guintptr
// Available G's (status == Gdead)
gfree *g
gfreecnt int32
sudogcache []*sudog
sudogbuf [128]*sudog
tracebuf traceBufPtr
// traceSweep indicates the sweep events should be traced.
// This is used to defer the sweep start event until a span
// has actually been swept.
traceSweep bool
// traceSwept and traceReclaimed track the number of bytes
// swept and reclaimed by sweeping in the current sweep loop.
traceSwept, traceReclaimed uintptr
palloc persistentAlloc // per-P to avoid mutex
// Per-P GC state
gcAssistTime int64 // Nanoseconds in assistAlloc
gcFractionalMarkTime int64 // Nanoseconds in fractional mark worker
gcBgMarkWorker guintptr
gcMarkWorkerMode gcMarkWorkerMode
// gcMarkWorkerStartTime is the nanotime() at which this mark
// worker started.
gcMarkWorkerStartTime int64
// gcw is this P's GC work buffer cache. The work buffer is
// filled by write barriers, drained by mutator assists, and
// disposed on certain GC state transitions.
gcw gcWork
// wbBuf is this P's GC write barrier buffer.
//
// TODO: Consider caching this in the running G.
wbBuf wbBuf
runSafePointFn uint32 // if 1, run sched.safePointFn at next safe point
pad [sys.CacheLineSize]byte
}
這些欄位裡面比較核心的欄位如下:
lock mutex
status uint32 // one of pidle/prunning/...
link puintptr
m muintptr // back-link to associated m (nil if idle)
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr
// Available G's (status == Gdead)
gfree *g
通過runtime.GOMAXPROCS函式我們可以改變單個Go程式可以擁有P的最大數量,如果不做設定會有一個預設值。
每一個P都必須關聯一個M才能使其中的G得以執行。
【注意】:runtime會將M與關聯的P分離開來。但是如果該P的runqueue中還有未執行的G,那麼runtime就會找到一個空的M(在排程器的空閒佇列中的M) 或者建立一個空的M,並與該P關聯起來(為了執行G而做準備)。
runtime.GOMAXPROCS只能夠設定P的數量,並不會影響到M(核心執行緒)數量,所以runtime.GOMAXPROCS不是控制執行緒數,只能影響上下文環境P的數量。
runtime在初始化時確認了P的最大數量之後,會根據這個最大值初始化全域性P列表【runtime.allp】,類似全域性M列表,【runtime.allp】包含了runtime建立的所有P。隨後,runtime會把排程器的可執行G佇列【runtime.schedt.runq】中的所有G均勻的放入全域性的P列表中的各個P的可執行G佇列local queue中。到這裡為止,runtime需要用到的所有P都準備就緒了。
類似M的空閒列表,排程器也存在一個空閒P的列表【runtime.shcedt.pidle】,當一個P不再與任何M關聯的時候,runtime會把該P放入這個列表,而一個空閒的P關聯了某個M之後會被從【runtime.shcedt.pidle】中取出來。【注意:一個P加入了空閒列表,其G的可執行local queue也不一定為空】。
和M不同,P是有狀態機的(五種):
- Pidel:當前P未和任何M關聯
- Prunning:當前P已經和某個M關聯,M在執行某個G
- Psyscall:當前P中的被執行的那個G正在進行系統呼叫
- Pgcstop:runtime正在進行GC(runtime會在gc時試圖把全域性P列表中的P都處於此種狀態)
- Pdead:當前P已經不再被使用(在呼叫runtime.GOMAXPROCS減少P的數量時,多餘的P就處於此狀態)
在對P初始化的時候就是Pgcstop的狀態,但是這個狀態保持時間很短,在初始化並填充P中的G佇列之後,runtime會將其狀態置為Pidle並放入排程器的空閒P列表【runtime.schedt.pidle】中,其中的P會由排程器根據實際情況進行取用。具體的狀態機流轉圖如下圖所示:
從上圖我們可以看到,除了Pdead狀態以外的其餘狀態,在runtime進行GC的時候,P都會被指定成Pgcstop。在GC結束後狀態不會回覆到GC前的狀態,而是都統一直接轉到了Pidle 【這意味著,他們都需要被重新排程】。
【注意】:除了Pgcstop 狀態的P,其他狀態的P都會在呼叫runtime.GOMAXPROCS 函式減少P數目時,被認為是多餘的P而狀態轉為Pdead,這時候其帶的可執行G的佇列中的G都會被轉移到排程器的可執行G佇列中,它的自由G佇列 【gfree】也是一樣被移到排程器的自由列表【runtime.sched.gfree】中。
【注意】:每個P中都有一個可執行G佇列及自由G佇列。自由G佇列包含了很多已經完成的G,隨著被執行完成的G的積攢到一定程度後,runtime會把其中的部分G轉移的排程器的自由G佇列 【runtime.sched.gfree】中。
【注意】:當我們每次用 go關鍵字啟用一個G的時候,執行時系統都會先從P的自由G佇列獲取一個G來封裝我們提供的函式 (go 關鍵字後面的函式) ,如果發現P中的自由G過少時,會從排程器的自由G佇列中移一些G過來,只有連排程器的自由G列表都彈盡糧絕的時候,才會去建立新的G。
G(goroutine)
goroutine可以理解成受排程器管理的輕量級執行緒,goroutine使用go關鍵字建立。
goroutine的新建, 休眠, 恢復, 停止都受到go的runtime管理。
goroutine執行非同步操作時會進入休眠狀態, 待操作完成後再恢復, 無需佔用系統執行緒。
goroutine新建或恢復時會新增到執行佇列, 等待M取出並執行。
g和gobuf的結構定義和在runtime2.go如下:
type g struct {
// Stack parameters.
// stack describes the actual stack memory: [stack.lo, stack.hi).
// stackguard0 is the stack pointer compared in the Go stack growth prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
// stackguard1 is the stack pointer compared in the C stack growth prologue.
// It is stack.lo+StackGuard on g0 and gsignal stacks.
// It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
_panic *_panic // innermost panic - offset known to liblink
_defer *_defer // innermost defer
/**
* 有一個指標指向執行它的m,也即g隸屬於m;
*/
m *m // current m; offset known to arm liblink
// 程序切換時,利用sched域來儲存上下文
sched gobuf
syscallsp uintptr // if status==Gsyscall, syscallsp = sched.sp to use during gc
syscallpc uintptr // if status==Gsyscall, syscallpc = sched.pc to use during gc
stktopsp uintptr // expected sp at top of stack, to check in traceback
param unsafe.Pointer // passed parameter on wakeup
// 狀態Gidle,Grunnable,Grunning,Gsyscall,Gwaiting,Gdead
atomicstatus uint32
stackLock uint32 // sigprof/scang lock; TODO: fold in to atomicstatus
goid int64
//????
schedlink guintptr
waitsince int64 // approx time when the g become blocked
waitreason waitReason // if status==Gwaiting
preempt bool // preemption signal, duplicates stackguard0 = stackpreempt
paniconfault bool // panic (instead of crash) on unexpected fault address
preemptscan bool // preempted g does scan for gc
gcscandone bool // g has scanned stack; protected by _Gscan bit in status
gcscanvalid bool // false at start of gc cycle, true if G has not run since last scan; TODO: remove?
throwsplit bool // must not split stack
raceignore int8 // ignore race detection events
sysblocktraced bool // StartTrace has emitted EvGoInSyscall about this goroutine
sysexitticks int64 // cputicks when syscall has returned (for tracing)
traceseq uint64 // trace event sequencer
tracelastp puintptr // last P emitted an event for this goroutine
// G被鎖定只能在這個m上執行
lockedm muintptr
sig uint32
writebuf []byte
sigcode0 uintptr
sigcode1 uintptr
sigpc uintptr
// 建立這個goroutine的go表示式的pc
gopc uintptr // pc of go statement that created this goroutine
ancestors *[]ancestorInfo // ancestor information goroutine(s) that created this goroutine (only used if debug.tracebackancestors)
startpc uintptr // pc of goroutine function
racectx uintptr
waiting *sudog // sudog structures this g is waiting on (that have a valid elem ptr); in lock order
cgoCtxt []uintptr // cgo traceback context
labels unsafe.Pointer // profiler labels
timer *timer // cached timer for time.Sleep
selectDone uint32 // are we participating in a select and did someone win the race?
// Per-G GC state
// gcAssistBytes is this G's GC assist credit in terms of
// bytes allocated. If this is positive, then the G has credit
// to allocate gcAssistBytes bytes without assisting. If this
// is negative, then the G must correct this by performing
// scan work. We track this in bytes to make it fast to update
// and check for debt in the malloc hot path. The assist ratio
// determines how this corresponds to scan work debt.
gcAssistBytes int64
}
//用於儲存G切換時上下文的快取結構體
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 //g自身引用
ctxt unsafe.Pointer
ret sys.Uintreg
lr uintptr
bp uintptr // for GOEXPERIMENT=framepointer
}
GO語言的編譯器會把我們編寫的goroutine程式設計為runtime的函式呼叫,並把go語句中的函式以及其引數傳遞給執行時系統函式中。
執行時系統在接到這樣一個呼叫後,會先檢查一下go函式及其引數的合法性,緊接著會試圖從本地P的自由G佇列中(或者排程器的自由G佇列)中獲取一個可用的自由G (P中有講述了),如果沒有則新建立一個G。類似M和P,G在執行時系統中也有全域性的G列表【runtime.allg】,那些新建的G會先放到這個全域性的G列表中,其列表的作用也是集中放置了當前執行時系統中給所有的G的指標。在用自由G封裝go的函式時,執行時系統都會對這個G重新做一次初始化。
初始化:包含了被關聯的go關鍵字後的函式及當前G的狀態機G的ID等等。在G被初始化完成後就會被放置到當前本地的P的可執行佇列中。只要時機成熟,排程器會立即盡心這個G的排程執行。
G的狀態機會比較複雜一點,大致上和核心執行緒的狀態機有一點類似,但是狀態機流轉有一些區別。G的各種狀態如下:
- Gidle:G被建立但還未完全被初始化。
- Grunnable:當前G為可執行的,正在等待被執行。
- Grunning:當前G正在被執行。
- Gsyscall:當前G正在被系統呼叫
- Gwaiting:當前G正在因某個原因而等待
- Gdead:當前G完成了執行
初始化完的G是處於Grunnable的狀態,一個G真正在M中執行時是處於Grunning的狀態,G的狀態機流轉圖如下圖所示:
上圖有一步是等待的事件到來,那麼G在執行過程中,是否等待某個事件以及等待什麼樣的事件?完全由起封裝的go關鍵字後的函式決定。(如:等待chan中的值、涉及網路I/O、time.Timer、time.Sleep等等事件)
G退出系統呼叫非常複雜:執行時系統先會嘗試直接運行當前G,僅當無法被執行時才會轉成Grunnable狀態並放置入排程器的global queue。
最後,已經是Gdead狀態的G是可以被重新初始化並使用的(從自由G佇列取出來重新初始化使用)。而對比進入Pdead狀態的P等待的命運只有被銷燬。處於Gdead的G會被放置到本地P或者排程器的自由G列表中。
至此,G、M、P的初步描述已經完畢,下面我們來看一看一些核心的佇列:
中文名 | 原始碼名稱 | 作用域 | 簡要說明 |
---|---|---|---|
全域性M列表 | runtime.allm | 執行時系統 | 存放所有M |
全域性P列表 | runtime.allp | 執行時系統 | 存放所有P |
全域性G列表 | runtime.allg | 執行時系統 | 存放所有G |
排程器中的空閒M列表 | runtime.schedt.midle | 排程器 | 存放空閒M,連結串列結構 |
排程器中的空閒P列表 | runtime.schedt.pidle | 排程器 | 存放空閒P,連結串列結構 |
排程器中的可執行G佇列 | runtime.schedt.runq | 排程器 | 存放可執行G,連結串列結構 |
排程器中的自由G列表 | runtime.schedt.gfree | 排程器 | 存放自由G, 連結串列結構 |
P中的可執行G佇列 | runq | 本地P | 存放當前P中的可執行G,環形佇列,陣列實現 |
P中的自由G列表 | gfree | 本地P | 存放當前P中的自由G,連結串列結構 |
三個全域性的列表主要為了統計執行時系統的的所有G、M、P。我們主要關心剩下的這些容器,尤其是和G相關的四個。
在執行時系統建立的G都會被儲存在全域性的G列表中,值得注意的是:
- 從Gsyscall轉出來的G,都會被放置到排程器的可執行佇列中(global queue)。
- 被執行時系統初始化的G會被放置到本地P的可執行佇列中(local queue)
- 從Gwaiting轉出來的G,除了因網路IO陷入等待的G之外,都會被防止到本地P可執行的G佇列中。
- 轉成Gdead狀態的G會先被放置在本地P的自由G列表。
- 排程器中的與G、M、P相關的列表其實只是起了一個暫存的作用。
一句話概括三者關係:
- G需要繫結在M上才能執行
- M需要繫結P才能執行
這三者之間的實體關係是:
核心排程實體(Kernel Scheduling Entry)與三者的關係是:
可知:一個G的執行需要M和P的支援。一個M在於一個P關聯之後就形成一個有效的G執行環境 【核心執行緒 + 上下文環境】。每個P都含有一個 可執行G的佇列【runq】。佇列中的G會被一次傳遞給本地P關聯的M並且獲得執行時機。
M 與 KSE 的關係是絕對的一對一,一個M僅能代表一個核心執行緒。在一個M的生命週期內,僅會和一個核心KSE產生關聯。M與P以及P與G之間的關聯時多變的,總是會隨著排程器的實際排程策略而變化。
這裡我們再回顧下G、M、P裡面核心成員
G裡面的核心成員
- stack :當前g使用的棧空間, 有lo和hi兩個成員
- stackguard0 :檢查棧空間是否足夠的值, 低於這個值會擴張棧, 0是go程式碼使用的
- stackguard1 :檢查棧空間是否足夠的值, 低於這個值會擴張棧, 1是原生程式碼使用的
- m :當前g對應的m
- sched :g的排程資料, 當g中斷時會儲存當前的pc和rsp等值到這裡, 恢復執行時會使用這裡的值
- atomicstatus: g的當前狀態
- schedlink: 下一個g, 當g在連結串列結構中會使用
- preempt: g是否被搶佔中
- lockedm: g是否要求要回到這個M執行, 有的時候g中斷了恢復會要求使用原來的M執行
M裡面的核心成員
- g0: 用於排程的特殊g, 排程和執行系統呼叫時會切換到這個g
- curg: 當前執行的g
- p: 當前擁有的P
- nextp: 喚醒M時, M會擁有這個P
- park: M休眠時使用的訊號量, 喚醒M時會通過它喚醒
- schedlink: 下一個m, 當m在連結串列結構中會使用
- mcache: 分配記憶體時使用的本地分配器, 和p.mcache一樣(擁有P時會複製過來)
- lockedg: lockedm的對應值
P裡面的核心成員
- status: p的當前狀態
- link: 下一個p, 當p在連結串列結構中會使用
- m: 擁有這個P的M
- mcache: 分配記憶體時使用的本地分配器
- runqhead: 本地執行佇列的出隊序號
- runqtail: 本地執行佇列的入隊序號
- runq: 本地執行佇列的陣列, 可以儲存256個G
- gfree: G的自由列表, 儲存變為_Gdead後可以複用的G例項
- gcBgMarkWorker: 後臺GC的worker函式, 如果它存在M會優先執行它
- gcw: GC的本地工作佇列, 詳細將在下一篇(GC篇)分析
排程器除了設計上面的三個結構體,還有一個全域性排程器資料結構schedt:
type schedt struct {
// accessed atomically. keep at top to ensure alignment on 32-bit systems.
// // 下面兩個變數需以原子訪問訪問。保持在 struct 頂部,確保其在 32 位系統上可以對齊
goidgen uint64
lastpoll uint64
lock mutex
// When increasing nmidle, nmidlelocked, nmsys, or nmfreed, be
// sure to call checkdead().
//=====與m數量相關的變數================================================
// 空閒m列表指標
midle muintptr // idle m's waiting for work
// 空閒m的數量
nmidle int32 // number of idle m's waiting for work
// 被鎖住的m空閒數量
nmidlelocked int32 // number of locked m's waiting for work
// 已經建立的m的數目和下一個m ID
mnext int64 // number of m's that have been created and next M ID
// 允許建立的m的最大數量
maxmcount int32 // maximum number of m's allowed (or die)
// 不計入死鎖的m的數量
nmsys int32 // number of system m's not counted for deadlock
// 釋放m的累計數量
nmfreed int64 // cumulative number of freed m's
//系統的goroutine的數量
ngsys uint32 // number of system goroutines; updated atomically
//=====與p數量相關的變數================================================
// 空閒的p列表
pidle puintptr // idle p's
// 空閒p的數量
npidle uint32
//
nmspinning uint32 // See "Worker thread parking/unparking" comment in proc.go.
// Global runnable queue.
// 全域性runable g連結串列的head地址
runqhead guintptr
// 全域性runable g連結串列的tail地址
runqtail guintptr
// 全域性runable g連結串列的大小
runqsize int32
// Global cache of dead G's.
gflock mutex
gfreeStack *g
gfreeNoStack *g
ngfree int32
// Central cache of sudog structs.
sudoglock mutex
sudogcache *sudog
// Central pool of available defer structs of different sizes.
deferlock mutex
deferpool [5]*_defer
// freem is the list of m's waiting to be freed when their
// m.exited is set. Linked through m.freelink.
freem *m
gcwaiting uint32 // gc is waiting to run
stopwait int32
stopnote note
sysmonwait uint32
sysmonnote note
// safepointFn should be called on each P at the next GC
// safepoint if p.runSafePointFn is set.
safePointFn func(*p)
safePointWait int32
safePointNote note
profilehz int32 // cpu profiling rate
procresizetime int64 // nanotime() of last change to gomaxprocs
totaltime int64 // ∫gomaxprocs dt up to procresizetime
}
全域性排程器,全域性只有一個schedt型別的例項。
sudoG 結構體:
// sudog 代表在等待列表裡的 g,比如向 channel 傳送/接收內容時
// 之所以需要 sudog 是因為 g 和同步物件之間的關係是多對多的
// 一個 g 可能會在多個等待佇列中,所以一個 g 可能被打包為多個 sudog
// 多個 g 也可以等待在同一個同步物件上
// 因此對於一個同步物件就會有很多 sudog 了
// sudog 是從一個特殊的池中進行分配的。用 acquireSudog 和 releaseSudog 來分配和釋放 sudog
type sudog struct {
// The following fields are protected by the hchan.lock of the
// channel this sudog is blocking on. shrinkstack depends on
// this for sudogs involved in channel ops.
g *g
selectdone *uint32 // CAS to 1 to win select race (may point to stack)
next *sudog
prev *sudog
elem unsafe.Pointer // data element (may point to stack)
// The following fields are never accessed concurrently.
// For channels, waitlink is only accessed by g.
// For semaphores, all fields (including the ones above)
// are only accessed when holding a semaRoot lock.
acquiretime int64
releasetime int64
ticket uint32
parent *sudog // semaRoot binary tree
waitlink *sudog // g.waiting list or semaRoot
waittail *sudog // semaRoot
c *hchan // channel
}
Section3 主要排程流程的原始碼分析
下面主要分析排程器排程流程的原始碼,主要分為幾個部分:
1)預備知識點
2)main程式啟動初始化過程(單獨一篇文章寫這個)
3)新建 goroutine的過程
4)迴圈排程的過程
5)搶佔式排程的實現
6)初始化P過程
7)初始化M過程
8)初始化G過程
3.1 預備知識
在學習原始碼之前,需要了解一些關於Golang的一些規範和預備知識。
3.1.1 golang的函式呼叫規範
在golang裡面呼叫函式,必須要關注的就是引數的傳入和返回值。Golang有自己的一套函式呼叫規範,這個規範定義,所有引數都通過棧傳遞,返回值也是通過棧傳遞。
比如,對於函式:
type MyStruct struct {
X int
P *int
}
func someFunc(x int, s MyStruct) (int, MyStruct) { ... }
呼叫函式時候,棧的內容如下:
可以看到,引數和返回值都是從低位到高位排列,go函式可以有多個返回值的原因也在此,因為返回值都通過棧傳遞了。
需要注意這裡的"返回地址"是x86和x64上的, arm的返回地址會通過LR暫存器儲存, 內容會和這裡的稍微不一樣.
另外注意的是go和c不一樣, 傳遞構造體時整個構造體的內容都會複製到棧上, 如果構造體很大將會影響效能。
3.1.2 TLS(thread local storage)
TLS全稱是Thread Local Storage,代表每個執行緒中的本地資料。寫入TLS中的資料不會干擾到其餘執行緒中的值。
Go的協程實現非常依賴於TLS機制,會用於獲取系統執行緒中當前的G和G所屬於的M例項。
Go操作TLS會使用系統原生的介面,以Linux X64為例,
go在新建M時候會呼叫arch_prctl 這個syscall來設定FS暫存器的值為M.tls的地址,
執行中每個M的FS暫存器都會指向它們對應的M例項的tls,linux核心排程執行緒時FS暫存器會跟著執行緒一起切換,
這樣go程式碼只需要訪問FS寄存機就可以獲取到執行緒本地的資料。
3.1.3 棧擴張
go的協程設計是stackful coroutine,每一個goroutine都需要有自己的棧空間,
棧空間的內容再goroutine休眠時候需要保留的,等到重新排程時候恢復(這個時候整個呼叫樹是完整的)。
這樣就會引出一個問題,如果系統存在大量的goroutine,給每一個goroutine都預先分配一個足夠的棧空間那麼go就會使用過多的記憶體。
為了避免記憶體使用過多問題,go在一開始時候,會預設只為goroutine分配一個很小的棧空間,它的大小在1.92版本中是2k。
當函式發現棧空間不足時,會申請一塊新的棧空間並把原來的棧複製過去。
g例項裡面的g.stack、g.stackguard0兩個變數來描述goroutine例項的棧。
3.1.4 寫屏障(write barrier)
go支援並行GC的,GC的掃描階段和go程式碼可以同時執行。這樣帶來的問題是,GC掃描的過程中go程式碼的執行可能改變了物件依賴樹。
比如:開始掃描時候發現根物件A和B,B擁有C的指標,GC先掃描A,然後B把C的指標交給A,GC再掃描B,這時C就不會被掃描到。
為了避免這個問題,go在GC掃描標記階段會啟用寫屏障(Write Barrier)
啟用了Write barrier之後,當B把C指標交給A時,GC會認為在這一輪掃描中C的指標是存活的,即使A 可能在稍後丟掉C,那麼C在下一輪GC中再回收。
Write barrier只針對指標啟用,而且只在GC的標記階段啟用,平時會直接把值寫入到目標地址。
3.1.5 m0和g0
go中有特殊的M和G,它們分別是m0和g0。
m0是啟動程式後的主執行緒,這個M對應的例項會在全域性變數runtime.m0中,不需要在heap上分配,
m0負責執行初始化操作和啟動第一個g, 在之後m0就和其他的m一樣了。
g0是僅用於負責排程的G,g0不指向任何可執行的函式, 每個m都會有一個自己的g0。
在排程或系統呼叫時會使用g0的棧空間, 全域性變數的g0是m0的g0。
3.1.6 go中執行緒的種類
在 runtime 中有三種執行緒:
- 一種是主執行緒,
- 一種是用來跑 sysmon 的執行緒,
- 一種是普通的使用者執行緒。
主執行緒在 runtime 由對應的全域性變數: runtime.m0 來表示。使用者執行緒就是普通的執行緒了,和 p 繫結,執行 g 中的任務。雖然說是有三種,實際上前兩種執行緒整個 runtime 就只有一個例項。使用者執行緒才會有很多例項。
主執行緒中用來跑 runtime.main,流程線性執行,沒有跳轉。
3.2 main執行緒啟動執行
main執行緒的啟動是伴隨著go的main goroutine一起啟動的,具體的啟動流程可看另外一篇博文:
Golang-bootstrap分析 裡面關於scheduler.main函式的分析。
3.3 新建goroutine過程
前面已經講過了,當我們用 go func() 建立一個寫的goroutine時候,compiler會編譯成對runtime.newproc()的呼叫。堆疊的結構如下:
runtime.newproc()原始碼如下:
// Create a new g running fn with siz bytes of argume