golang 原始碼分析之scheduler排程器

nevermoress發表於2020-11-17
單執行緒排程器 · 0.x

只包含 40 多行程式碼;

程式中只能存在一個活躍執行緒,由 G-M 模型組成;

多執行緒排程器 · 1.0

允許執行多執行緒的程式;

全域性鎖導致競爭嚴重;

任務竊取排程器 · 1.1

引入了處理器 P,構成了目前的 G-M-P 模型;

在處理器 P 的基礎上實現了基於工作竊取的排程器;

在某些情況下,Goroutine 不會讓出執行緒,進而造成飢餓問題;

時間過長的垃圾回收(Stop-the-world,STW)會導致程式長時間無法工作;

搶佔式排程器 · 1.2 ~ 至今
基於協作的搶佔式排程器 - 1.2 ~ 1.13

通過編譯器在函式呼叫時插入搶佔檢查指令,在函式呼叫時檢查當前 Goroutine 是否發起了搶佔請求,實現基於協作的搶佔式排程

Goroutine 可能會因為垃圾回收和迴圈長時間佔用資源導致程式暫停;

基於訊號的搶佔式排程器 - 1.14 ~ 至今

實現基於訊號的真搶佔式排程;

垃圾回收在掃描棧時會觸發搶佔排程;

搶佔的時間點不夠多,還不能覆蓋全部的邊緣情況;

非均勻儲存訪問排程器 · 提案

對執行時的各種資源進行分割槽;

實現非常複雜,到今天還沒有提上日程;

協作的搶佔式排程器

1 編譯器會在呼叫函式前插入 runtime.morestack;

2 Go 語言執行時會在垃圾回收暫停程式、系統監控發現 Goroutine 執行超過 10ms 時發出搶佔請求 StackPreempt;

3 當發生函式呼叫時,可能會執行編譯器插入的 runtime.morestack 函式,它呼叫的 runtime.newstack 會檢查 Goroutine 的 stackguard0 欄位是否為 StackPreempt;

4 如果 stackguard0 是 StackPreempt,就會觸發搶佔讓出當前執行緒;

非協作的搶佔式排程

1 程式啟動時,在 runtime.sighandler 函式中註冊 SIGURG 訊號的處理函式 runtime.doSigPreempt;

2 在觸發垃圾回收的棧掃描時會呼叫 runtime.suspendG 掛起 Goroutine,該函式會執行下面的邏輯:

1.將 _Grunning 狀態的 Goroutine 標記成可以被搶佔,即將 preemptStop 設定成 true;

2.呼叫 runtime.preemptM 觸發搶佔

3 runtime.preemptM 會呼叫 runtime.signalM 向執行緒傳送訊號 SIGURG;

4 作業系統會中斷正在執行的執行緒並執行預先註冊的訊號處理函式 runtime.doSigPreempt;

5 runtime.doSigPreempt 函式會處理搶佔訊號,獲取當前的 SP 和 PC 暫存器並呼叫 runtime.sigctxt.pushCall

6 runtime.sigctxt.pushCall 會修改暫存器並在程式回到使用者態時執行runtime.asyncPreempt;

7 彙編指令 runtime.asyncPreempt 會呼叫執行時函式 runtime.asyncPreempt2;

8 runtime.asyncPreempt2 會呼叫 runtime.preemptPark;

9 runtime.preemptPark 會修改當前 Goroutine 的狀態到 _Gpreempted 並呼叫 runtime.schedule 讓當前函式陷入休眠並讓出執行緒,排程器會選擇其它的 Goroutine 繼續執行;

// src/runtime/runtime2.go
type m struct {
	g0          *g			// 用於執行排程指令的 goroutine
	gsignal     *g			// 處理 signal 的 g
	tls         [6]uintptr	// 執行緒本地儲存
	curg        *g			// 當前執行的使用者 goroutine
	p           puintptr	// 執行 go 程式碼時持有的 p (如果沒有執行則為 nil)
	spinning    bool		// m 當前沒有執行 work 且正處於尋找 work 的活躍狀態
	cgoCallers  *cgoCallers	// cgo 呼叫崩潰的 cgo 回溯
	alllink     *m			// 在 allm 上
	mcache      *mcache

	...
}

type p struct {
	id           int32
	status       uint32 // p 的狀態 pidle/prunning/...
	link         puintptr
	m            muintptr   // 反向連結到關聯的 m (nil 則表示 idle)
	mcache       *mcache
	pcache       pageCache
	deferpool    [5][]*_defer // 不同大小的可用的 defer 結構池
	deferpoolbuf [5][32]*_defer
	runqhead     uint32	// 可執行的 goroutine 佇列,可無鎖訪問
	runqtail     uint32
	runq         [256]guintptr
	runnext      guintptr
	timersLock   mutex
	timers       []*timer
	preempt      bool
	...
}

type g struct {
	stack struct {
		lo uintptr
		hi uintptr
	} 							// 棧記憶體:[stack.lo, stack.hi)
	stackguard0	uintptr
	stackguard1 uintptr

	_panic       *_panic
	_defer       *_defer
	m            *m				// 當前的 m
	sched        gobuf
	stktopsp     uintptr		// 期望 sp 位於棧頂,用於回溯檢查
	param        unsafe.Pointer // wakeup 喚醒時候傳遞的引數
	atomicstatus uint32
	goid         int64
	preempt      bool       	// 搶佔訊號,stackguard0 = stackpreempt 的副本
	timer        *timer         // 為 time.Sleep 快取的計時器

	...
}

type schedt struct {
	lock mutex

	pidle      puintptr	// 空閒 p 連結串列
	npidle     uint32	// 空閒 p 數量
	nmspinning uint32	// 自旋狀態的 M 的數量
	runq       gQueue	// 全域性 runnable G 佇列
	runqsize   int32
	gFree struct {		// 有效 dead G 的全域性快取.
		lock    mutex
		stack   gList	// 包含棧的 Gs
		noStack gList	// 沒有棧的 Gs
		n       int32
	}
	sudoglock  mutex	// sudog 結構的集中快取
	sudogcache *sudog
	deferlock  mutex	// 不同大小的有效的 defer 結構的池
	deferpool  [5]*_defer
	
	...
}
runtime.newproc1 獲取 Goroutine 結構體的三種方法

1 當處理器的 Goroutine 列表為空時,會將排程器持有的空閒 Goroutine 轉移到當前處理器上,直到 gFree 列表中的 Goroutine 數量達到 32;

2 當處理器的 Goroutine 數量充足時,會從列表頭部返回一個新的 Goroutine;

3 當排程器的 gFree 和處理器的 gFree 列表都不存在結構體時,執行時會呼叫 runtime.malg 初始化一個新的 runtime.g 結構體,如果申請的堆疊大小大於 0,在這裡我們會通過 runtime.stackalloc 分配 1KB 的棧空間:

總結:runtime.newproc1 會從處理器或者排程器的快取中獲取新的結構體,也可以呼叫 runtime.malg 函式建立新的結構體。

執行佇列

runtime.runqput 函式會將新建立的 Goroutine 執行佇列上,這既可能是全域性的執行佇列,也可能是處理器本地的執行佇列

// runqput嘗試將g放置在本地可執行佇列中。
// 如果next為false,則runqput將g新增到可執行佇列的尾部。
// 如果next為true,則runqput將g放入_p_.runnext中。
// 如果執行佇列已滿,則runnext將g放入全域性佇列。
// Executed only by the owner P.
func runqput(_p_ *p, gp *g, next bool) {
	if randomizeScheduler && next && fastrand()%2 == 0 {
		next = false
	}

	if next {
	retryNext:
		oldnext := _p_.runnext
		if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
			goto retryNext
		}
		if oldnext == 0 {
			return
		}
		// Kick the old runnext out to the regular run queue.
		gp = oldnext.ptr()
	}

retry:
	h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers
	t := _p_.runqtail
	if t-h < uint32(len(_p_.runq)) {
		_p_.runq[t%uint32(len(_p_.runq))].set(gp)
		atomic.StoreRel(&_p_.runqtail, t+1) // store-release, makes the item available for consumption
		return
	}
	if runqputslow(_p_, gp, h, t) {
		return
	}
	// the queue is not full, now the put above must succeed
	goto retry
}
//從本地執行佇列、全域性執行佇列中查詢
//從網路輪詢器中查詢是否有 Goroutine 等待執行
//通過 runtime.runqsteal 函式嘗試從其他隨機的處理器中竊取待執行的 Goroutine,在該過程中還可能竊取處理器中的計時器;
//總而言之,當前函式一定會返回一個可執行的 Goroutine,如果當前不存在就會阻塞等待。
func schedule() {
 ...
}
排程時間點
1 主動掛起 — runtime.gopark -> runtime.park_m
2 系統呼叫 — runtime.exitsyscall -> runtime.exitsyscall0
3 協作式排程 — runtime.Gosched -> runtime.gosched_m -> runtime.goschedImpl
4 系統監控 — runtime.sysmon -> runtime.retake -> runtime.preemptone

相關文章