1. 程式人生 > >Golang-Scheduler原理解析

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。

  1. G:代表一個goroutine,它有自己的棧記憶體,instruction pointer和其餘資訊(等待的channel等等),用於排程。
  2. M:代表一個真正的核心OS執行緒,和POSIX裡的thread差不多,真正幹活的人。
  3. P:代表M排程的上下文,可以把它看做一個區域性的排程器,使go程式碼在一個執行緒上跑,它是實現N:M對映的關鍵。P的上限是通過runtime.GOMAXPROCS (numLogicalProcessors)來控制的,而且是系統啟動時固定的,一般不建議修改這個值,P的數量也代表了併發度,即有多少goroutine可以同時執行。
  4. schedt:全域性排程使用的資料結構,這個實體只是一個殼,裡面主要有M的idle佇列,P的idle佇列,一個全域性的就緒的G佇列以及一個排程器級別的鎖,當對M或P等做一些非區域性的操作,它們一般需要先鎖住排程器。

為了解釋清楚這幾個實體之間的關係,我們先抽象G、M、P、schedt的關係,主要的workflow如下圖所示:
scheduler workflow

從上圖我們可以分析出幾個結論:

  1. 我們通過 go func()來建立一個goroutine;
  2. goroutine有兩個儲存佇列,一個是區域性排程抽象P的local queue、一個是全域性排程器資料模型schedt的global queue。新建立的goroutine會先儲存在local queue,如果local queue已經滿了就會儲存在全域性的global queue。
  3. goroutine只能執行在M中,一個M必須持有一個P,M與P是1:1的關係,M會從P的local queue彈出一個goroutine來執行,如果P的local queue空了,就會執行work stealing。
  4. 一個M排程goroutine執行是一個loop。
  5. 當M執行某一個goroutine時候如果發生了syscall或則其餘阻塞操作,阻塞的系統呼叫會中斷(intercepted),如果當前有一些G在執行,執行時會把這個執行緒M從P中摘除(detach),然後再建立一個新的作業系統的執行緒(如果沒有空閒的執行緒可用的話)來服務於這個P。
  6. 當系統呼叫結束時候,這個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會由排程器根據實際情況進行取用。具體的狀態機流轉圖如下圖所示:

p_state_machine

從上圖我們可以看到,除了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的狀態機流轉圖如下圖所示:
scheduler_G_state_machine

上圖有一步是等待的事件到來,那麼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列表中,值得注意的是:

  1. 從Gsyscall轉出來的G,都會被放置到排程器的可執行佇列中(global queue)。
  2. 被執行時系統初始化的G會被放置到本地P的可執行佇列中(local queue)
  3. 從Gwaiting轉出來的G,除了因網路IO陷入等待的G之外,都會被防止到本地P可執行的G佇列中。
  4. 轉成Gdead狀態的G會先被放置在本地P的自由G列表。
  5. 排程器中的與G、M、P相關的列表其實只是起了一個暫存的作用。

一句話概括三者關係:

  • G需要繫結在M上才能執行
  • M需要繫結P才能執行

這三者之間的實體關係是:
G、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