Golang的GMP排程模型與原始碼解析

MelonTe發表於2024-11-17

0、引言

我們知道,這當代作業系統中,多執行緒和多程序模型被廣泛的使用以提高系統的併發效率。隨著網際網路不斷的發展,面對如今的高併發場景,為每個任務都建立一個執行緒是不現實的,使用執行緒則需要系統不斷的在使用者態和核心態之間不斷的切換,引起不必要的損耗,於是引入了協程。協程存在於使用者空間,是一種輕量級的併發執行單元,其建立和上下文的開銷更小,如何管理數量眾多的協程是一個重要的話題。此篇筆記用於分享筆者學習Go語言協程排程的GMP模型的理解,以及原始碼的實現。當前使用的Go語言版本為1.22.4。

本篇筆記參考了以下文章:

[Golang三關-典藏版] Golang 排程器 GMP 原理與排程全分析 | Go 技術論壇

Golang GMP 原理

Golang-gopark函式和goready函式原理分析

1、GMP模型拆解

Goroutine排程器的工作是將準備執行的goroutine分配到工作執行緒上,涉及到的主要概念如下:

1.1、G

G代表的是Goroutine,是Go語言對協程概念的抽象,其有以下的特點:

  • 是一個輕量級的執行緒
  • 擁有自己的棧、狀態、以及執行的任務函式
  • 每一個G會被分配到一個可用的P,並且在M上執行

其結構定義位於runtime/runtime2.go中:

type g struct {
    // ...
    m         *m      
    // ...
    sched     gobuf
    // ...
}

type gobuf struct {
    sp   uintptr
    pc   uintptr
    ret  uintptr
    bp   uintptr // for framepointer-enabled architectures
}

在這裡,我們核心關注其內嵌了一個m和一個gobuf型別的sched。gobuf主要用於Gorutine的上下文切換,其儲存了G執行過程中的CPU暫存器的狀態,使得G在暫停、排程和恢復執行時能夠正確地恢復上下文。

G主要有以下幾種狀態:

const (
	_Gidle = iota // 0
	_Grunnable // 1
	_Grunning // 2
	_Gsyscall // 3
	_Gwaiting // 4
    //...
	_Gdead // 6
    //...
	_Gcopystack // 8
    _Gpreempted // 9
	//...
)
  • Gidle:表示這個G剛剛被分配,尚未初始化。

  • Grunnable:表示這個G在執行佇列中,它當前不再執行使用者程式碼,棧未被佔用。

  • Grunning:表示這個G可能在執行使用者程式碼,棧被這個G佔用,它不在執行佇列中,並且它被分配給了一個M和一個P(g.m和g.m.p是有效的)。

  • Gsyscall:表示這個G正在執行系統呼叫,它不在執行使用者程式碼,棧被這個G佔用。它不在執行佇列中,並且它被分配給了一個M。

  • Gwaiting:表示這G被堵塞在執行時,它沒有執行使用者程式碼,也不在執行佇列中,但是它應該被記錄在某個地方,以便在必要時將其喚醒。(ready())gc、channel 通訊或者鎖操作時經常會進入這種狀態。

  • Gdead:表示這個G當前未使用,它可能是剛被初始化,也可能是已經被銷燬。

  • Gcopystack:表示這個G的棧正在被移動。

  • Gpreempted:表示這個G因搶佔而被掛起,且該G自行停止,等待進一步的恢復。它類似於Gwaiting,但是Gpreempted還沒有一個負責將其狀態恢復的管理者,只有某個suspendG操作將該G的狀態從Gpreempted轉換為Gwaiting,這樣排程器才會接管這個G。

在閱讀有關排程邏輯的原始碼的時候,我們可以透過搜尋casgstatus方法去定位到使得G狀態改變的函式,例如:casgstatus(gp, _Grunning, _Gsyscall)表示將該G的狀態從Grunning變換到Gsyscall,就可以找到對應的函式學習了。

1.2、M

M是Machine,也是Worker Thread,代表的是作業系統的執行緒。Go執行時在需要時建立或者銷燬M,將G安排到M上執行,充分利用多核CPU的能力。其具有以下的特點:

  • M是Go與作業系統之間的橋樑,它負責執行分配給它的G。
  • M的數量會根據系統資源進行調整。
  • M可能會被特定的G透過LockOSThread鎖定,這種G和M的繫結確保了特定Goroutine可以持續使用同一個執行緒。

結構定義如下:

type m struct{
	g0      *g     // goroutine with scheduling stack
	curg          *g       // current running goroutine
	tls           [tlsSlots]uintptr // thread-local storage (for x86 extern register)
	p             puintptr // attached p for executing go code (nil if not executing go code)
	oldp          puintptr // the p that was attached before executing a syscall
	//...
}

每一個M結構體都會有一個名為g0的G,它是一個特殊的Goroutine,它並不複雜執行使用者的程式碼,而是負責排程G。g0會分配G繫結到M中執行。tls表示的是“Local Thread Storage”,其儲存了與當前執行緒相關的特定資訊,而tls陣列的第一個槽位通常用於儲存g0的棧指標。

M存在一個狀態,名為“自旋態”,處在自旋態的M會不斷的往全域性佇列中尋找可執行的G去執行,並且解除自旋態。

1.3、P

P是Processor,代表邏輯處理器,是Goroutine排程的虛擬概念。每個P負責分配執行Goroutine的資源,其具有以下的特點:

  • P是G的執行上下文,它具有一個本地佇列儲存著G,以及對應的任務排程機制,負責在M上執行一個具體的G。
  • P的數量由環境變數GOMAXPROCS決定,如果其數量大於CPU的物理執行緒數量時就沒有更多的意義了。
  • P是去執行Go程式碼所必備的資源,M必須繫結了一個P才能去執行Go程式碼。但是M可以在沒有繫結P的情況下執行系統呼叫或者被阻塞。
type p struct {
	status      uint32
	runqhead uint32
	runqtail uint32
	runq     [256]guintptr
	m           muintptr
	runnext guintptr
	//...
}
  • runq儲存了這個P具有的goroutine佇列,最大長度為256
  • runqhead和runqtail分別指向佇列的頭部和尾部
  • runnext儲存了下一個可執行的goroutine

P也含有幾個狀態,如下:

const (
	_Pidle = iota
	_Prunning
	_Psyscall
	_Pgcstop
	_Pdead
)
  • Pidle:表示P沒有被執行使用者程式碼或者排程器,通常這個P在空閒P列表中,供排程器使用,但它也可能在其他狀態之間轉換。P由空閒佇列idle list或者其他轉換其狀態的物件擁有,它的runq是空的。
  • Prunning:表示P被M擁有,並且正在執行使用者程式碼或者排程器。只有擁有此P的M被允許更改P的狀態,M可以將P轉換為Pidle(當沒有工作的時候)、Psyscall(當進入一個系統呼叫時)、Pgcstop(安頓垃圾回收時)。M還可以將P的所有權交接給另一個M(例如排程一個locked的G)
  • Psyscall:表示P沒有在執行使用者程式碼,與在系統呼叫中的M相關但不被其擁有。處於Psyscall狀態的P可能會被其他M搶走。將P轉換給另一個M是輕量級的,並且P會保持和原始的M的關聯性。
  • Pgcstop:表示P被暫停以進行STW(Stop The World)(執行垃圾回收)。
  • Pdead:表示P不再被使用(GOMAXPROCS減少)。死去的P將會被剝奪資源,但是任然會保留少量的資源例如Trace Buffer,用於後續的跟蹤分析需求。

1.4、Schedt

schedt是全域性goroutine佇列的封裝

type schedt struct {
    // ...
    lock mutex
    // ...
    runq     gQueue
    runqsize int32![](https://img2024.cnblogs.com/blog/3542244/202411/3542244-20241117153220788-1594654379.png)

    // ...
}
  • lock:是操作全域性佇列的鎖
  • runq:儲存G的佇列
  • runqsize:全域性G佇列的容量

2、排程模型的工作流程

我們可以用下圖來整體的表示該排程模型的流程:

在接下來的部分,我們將主要探討GMP排程模型是怎麼完成一輪排程的,即是如何完成g0到g再到g0的切換的,期間大致發生了什麼。

2.1、G的狀態轉換

我們剛剛提及到,每一個M都有一個名為g0的Goroutine,去負責排程普通的g繫結到M上執行。g0和普通的g之間存在一個轉換,當執行普通的g上的程式碼的時候,就會將執行權交給g,當g執行完程式碼或者因為原因需要被掛起、退出執行等,就會重新將執行權交給g0。

g0和P是一個協作的關係,P的佇列決定了哪些goroutine可以在繫結P時被呼叫,而g0是執行排程邏輯的關鍵的goroutine,負責在必要時釋放P的資源。

當g0需要將執行權交給g時,會呼叫一個名為gogo的方法,傳入g的棧指標,去執行使用者的程式碼。

func gogo(buf *gobuf)

當需要重新將執行權轉交給g0時,都會執行一個名為mcall的方法。

func mcall(fn func(*g))

mcall在go需要進行協程調換時被呼叫,它傳入一個回撥函式fn,裡面攜帶了當前正在執行的g的指標,它主要做了以下三點的工作:

  • 儲存當前g的資訊,即將PC/SP的資訊儲存到g->sched中,保證後續可以恢復g的執行現場。
  • 將當前M的堆疊從g切換到g0
  • 在g0的棧上執行新的函式fn,通常在fn中會進一步安排g的去向,並且呼叫schedule函式,讓當前M去尋找另一個可以執行的G。

2.2、排程型別

我們現在知道了,g和g0是透過什麼函式進行狀態切換的。接下來我們就要來探討,它們是什麼情況下要進行切換,即排程策略有什麼。

GMP排程模型一共有4種排程策略,分別為:主動排程被動排程正常排程搶佔排程

  • 主動排程:提供給使用者的方法,當使用者呼叫了runtime.Gosched()方法時,此時當前的g會讓出執行權,將g安排進任務佇列等待下一次被排程。
  • 被動排程:當因不滿足某種執行條件,通常為channel讀寫條件不滿足時,會執行gopark()函式,此時的g將會被置為等待狀態。
  • 正常排程:g正常的執行完畢,轉接執行權。
  • 搶佔排程:存在一個全域性監控者moniter,它會每隔一段時間週期去檢查是否有G執行太長時間,若發現了,將會通知P去進行和M的解綁,讓出P。這裡需要全域性監控者的存在是因為當G進入到系統呼叫的時候,這個執行緒M會陷入僵持,無法主動去檢查,需要外援輔助。

2.3、宏觀排程流程

接下來我們來關注整體一輪的排程流程,對於g0和g的一輪排程,可以用下圖來表示。

schedule作為每一輪排程的開始,它會尋找到可以執行的G,然後呼叫execute將該g繫結到一個執行緒M上,然後執行gogo方法去真正的執行一個goroutine。當需要轉換時,goroutine會在底層執行mcall方法,儲存棧資訊,然後執行回撥函式fn,即綠框內的方法之一,將執行權重新交給g0。

2.3.1、schedule()

schedule()方法定位於runtime/proc中,忽略非主流程部分,原始碼內容如下:

//找到一個是就緒態的G去執行
func schedule() {
	mp := getg().m

	//...

top:
	pp := mp.p.ptr()
	pp.preempt = false

	//如果該M在自旋,但是佇列含有G,那麼丟擲異常。
	if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
		throw("schedule: spinning with local work")
	}

	gp, inheritTime, tryWakeP := findRunnable() //阻塞的尋找G

	
    //...

	//當前M將要運轉一個G,解除自旋狀態
	if mp.spinning {
		resetspinning()
	}

	//...

	execute(gp, inheritTime)
}

該方法主要是尋找一個可以執行的G,交給該執行緒去執行。我們在一開始提到,執行緒會存在一種名為“自旋態”的狀態,它會不斷的自旋去尋找可以執行的G來執行,成功找到了就解除了自旋態。

這裡存在一個點我們值得去注意,處在自旋態的執行緒它不是在空佔用計算資源嗎?那麼不就是降低了系統的效能嗎?

其實這是一箇中和的策略,假如每次當出現了一個新的Goroutine需要去執行的時候,我們才建立一個執行緒M去執行它,然後執行完了又刪除掉不去複用,那麼就會帶來大量的建立銷燬的資源消耗。我們希望當有一個新的Goroutine來的時候,能立即有一個M去執行它,就可以將空閒暫時無任務處理的M去自己尋找Goroutine,減少了建立銷燬的資源消耗。但是我們也不能有太多的處於自旋態的執行緒,不然就造就另一個過多消耗的地方了。

我們先跟進一下resetspinning(),看看其執行的策略是什麼。

1、resetspinning()

func resetspinning() {
	gp := getg()
	//...
	gp.m.spinning = false
	nmspinning := sched.nmspinning.Add(-1)
	//...
	wakep()
}



//嘗試新增一個P去執行G。該方法被呼叫當一個G狀態為runnable時。
func wakep() {
    //如果自旋的M數量不為0則返回
	if sched.nmspinning.Load() != 0 || !sched.nmspinning.CompareAndSwap(0, 1) {
		return
	}

	// 禁用搶佔,直到 pp 的所有權轉移到 startm 中的下一個 M,否則在這裡的搶佔將導致 pp 被卡在等待進入 _Pgcstop 狀態。
	mp := acquirem()

	var pp *p
	lock(&sched.lock)
    //嘗試從空閒P佇列獲取一個P
	pp, _ = pidlegetSpinning(0)
	if pp == nil {
		if sched.nmspinning.Add(-1) < 0 {
			throw("wakep: negative nmspinning")
		}
		unlock(&sched.lock)
		releasem(mp)
		return
	}
	
	unlock(&sched.lock)

	startm(pp, true, false)

	releasem(mp)
}

resetspinning中,我們先將當前M解除了自旋態,然後嘗試去喚醒一個P,即進入到wakep()方法中。

if sched.nmspinning.Load() != 0 || !sched.nmspinning.CompareAndSwap(0, 1) {
		return
	}

在wakep方法內,我們先檢查了當前處在自旋的M的數量,假如>0,則不再去喚醒一個新的P,這是為了防止同一時間內過多的自旋的M空運轉消耗CPU資源。

pp, _ = pidlegetSpinning(0)
	if pp == nil {
		if sched.nmspinning.Add(-1) < 0 {
			throw("wakep: negative nmspinning")
		}
		unlock(&sched.lock)
		releasem(mp)
		return
	}

接著會嘗試從空閒P佇列中獲取一個P,如果沒有空閒的P,那麼此時會減少自旋執行緒的數量(這裡只是減少了數量,但是具體這個處在自旋的執行緒接下來去做什麼了我也沒有明白)並且返回。

startm(pp, true, false)

假如獲取了一個空閒的P,會為這一個P分配一個執行緒M。

2、findRunnable()

findRunnable是一輪排程流程中最核心的方法,它用於找到一個可執行的G。

func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
	mp := getg().m
top:
    pp := mp.p.ptr()
	//...
 	
    //每61次排程週期就檢查一次全域性G佇列,防止在特定情況只依賴於本地佇列。
	if pp.schedtick%61 == 0 && sched.runqsize > 0 {
		lock(&sched.lock)
		gp := globrunqget(pp, 1)
		unlock(&sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}
    //...
    // local runq
	if gp, inheritTime := runqget(pp); gp != nil {
		return gp, inheritTime, false
	}

	// global runq
	if sched.runqsize != 0 {
		lock(&sched.lock)
		gp := globrunqget(pp, 0)
		unlock(&sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}
    
    //在正式的去偷取G之前,用非阻塞的方式檢查是否有就緒的網路協程,這是對netpoll的一個最佳化。
	if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 {
		if list, delta := netpoll(0); !list.empty() { // non-blocking
			gp := list.pop()
			injectglist(&list)
			netpollAdjustWaiters(delta)
			trace := traceAcquire()
			casgstatus(gp, _Gwaiting, _Grunnable)
			if trace.ok() {
				trace.GoUnpark(gp, 0)
				traceRelease(trace)
			}
			return gp, false, false
		}
	}
    
    //如果當前的M出於自旋狀態,或者說處於自旋狀態的M的數量小於活躍的P數量的一半時,則進行G竊取。(防止當系統的並行度較低時,自旋的M過多佔用CPU資源)
	if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
		if !mp.spinning {
			mp.becomeSpinning()
		}

		gp, inheritTime, tnow, w, newWork := stealWork(now)
		if gp != nil {
			// Successfully stole.
			return gp, inheritTime, false
		}
		if newWork {
			// There may be new timer or GC work; restart to
			// discover.
			goto top
		}

		now = tnow
		if w != 0 && (pollUntil == 0 || w < pollUntil) {
			// Earlier timer to wait for.
			pollUntil = w
		}
	}
    
    //...

其主要的執行步驟如下:

(一)第六十一次排程
if pp.schedtick%61 == 0 && sched.runqsize > 0 {
		lock(&sched.lock)
		gp := globrunqget(pp, 1)
		unlock(&sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}

首先檢查P的排程次數,假如這次是P的第61此次排程,並且全域性的G佇列長度>0,就會從全域性佇列獲取一個G。這是為了防止在特定情況下,只執行本地佇列的G,忽視了全域性佇列。

其內部呼叫的globrunqget方法主流程如下:

//嘗試從G的全域性佇列獲取一批G
func globrunqget(pp *p, max int32) *g {
	assertLockHeld(&sched.lock)
	//檢查全域性佇列是否為空
	if sched.runqsize == 0 {
		return nil
	}

    //計算需要獲取的G的數量
	n := sched.runqsize/gomaxprocs + 1
	if n > sched.runqsize {
		n = sched.runqsize
	}
	if max > 0 && n > max {
		n = max
	}
    //確保從佇列中獲取的G數量不超過當前本地佇列的G數量的一半,避免全域性佇列所有的G都轉移到本地佇列中導致負載不均衡
	if n > int32(len(pp.runq))/2 {
		n = int32(len(pp.runq)) / 2
	}
	sched.runqsize -= n

	gp := sched.runq.pop()
	n--
	for ; n > 0; n-- {
		gp1 := sched.runq.pop()
		runqput(pp, gp1, false)
	}
	return gp
}
//計算需要獲取的G的數量
	n := sched.runqsize/gomaxprocs + 1
	if n > sched.runqsize {
		n = sched.runqsize
	}
	if max > 0 && n > max {
		n = max
	}
	if n > int32(len(pp.runq))/2 {
		n = int32(len(pp.runq)) / 2
	}

n為要從全域性G佇列獲取的G的數量,可以看到它會至少獲取一個G,至多獲取runqsize/gomaxprocs+1個G,它保證了一個P不過多的獲取G從而影響負載均衡。並且不允許n一次獲取全域性G佇列一半以上的G,保證負載均衡。

gp := sched.runq.pop()
	n--
	for ; n > 0; n-- {
		gp1 := sched.runq.pop()
		runqput(pp, gp1, false)
	}

決定好獲取多少個G後,第一個G會直接透過指標返回,剩餘的則是將其新增到P的本地佇列中。

在當前(一)的呼叫中,函式設定了max值為1,因此只會從全域性佇列獲取1個G返回。


雖然在(一)中不會執行runqput,但是我們還是來看看是怎麼將G新增到P的本地佇列的。

// runqput嘗試將G放到本地佇列中
//如果next是False,runqput會將G新增到本地佇列的尾部
//如果是True,runqput會將G新增到下一個將被排程的G的槽位
//如果執行佇列滿了,那麼將會把g放回全域性佇列
func runqput(pp *p, gp *g, next bool) {
    //
	if randomizeScheduler && next && randn(2) == 0 {
		next = false
	}

	if next {
	retryNext:
		oldnext := pp.runnext
		if !pp.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(&pp.runqhead) //載入佇列頭的位置
	t := pp.runqtail
	if t-h < uint32(len(pp.runq)) { //檢查本地佇列是否已滿
		pp.runq[t%uint32(len(pp.runq))].set(gp) //未滿將gp插入runqtail的指定位置
		atomic.StoreRel(&pp.runqtail, t+1) //更新runtail,表示插入的G可供消費
		return
	}
	if runqputslow(pp, gp, h, t) { //如果本地佇列已滿,則嘗試放回全域性佇列
		return
	}
	// the queue is not full, now the put above must succeed
	goto retry
}
if randomizeScheduler && next && randn(2) == 0 {
		next = false
	}

在第一步中,我們看到即使next被設定為true,即要求了該G應該被放置在本地P佇列的runnext槽位中,也會有機率地將next置為false

if next {
	retryNext:
		oldnext := pp.runnext
		if !pp.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()
	}

假如next仍為true,此時先獲取原本P排程器中,runnext槽位的G(oldnext),然後會不斷地嘗試將新的G替換掉舊的G直到成功為止。當成功之後,在下面的操作流程中會把舊的G放入到P的本地佇列中。

retry:
	h := atomic.LoadAcq(&pp.runqhead) //載入佇列頭的位置
	t := pp.runqtail
	if t-h < uint32(len(pp.runq)) { //檢查本地佇列是否已滿
		pp.runq[t%uint32(len(pp.runq))].set(gp) //未滿將gp插入runqtail的指定位置
		atomic.StoreRel(&pp.runqtail, t+1) //更新runtail,表示插入的G可供消費
		return
	}
	if runqputslow(pp, gp, h, t) { //如果本地佇列已滿,則嘗試放回全域性佇列
		return
	}
	// the queue is not full, now the put above must succeed
	goto retry
}

在將G加入進P的本地佇列的流程中,需要獲取佇列頭部和尾部的座標,用來判斷本地佇列是否已滿,未滿則將G插入進本地佇列的尾部中。否則執行runqputslow方法,嘗試放回全域性佇列。


接下來繼續跟進runqputslow方法的執行流程。

//將G和一批工作(本地佇列的G)放入到全域性佇列
func runqputslow(pp *p, gp *g, h, t uint32) bool {
	var batch [len(pp.runq)/2 + 1]*g //本地佇列一半的G

	// First, grab a batch from local queue.
	n := t - h
	n = n / 2
	if n != uint32(len(pp.runq)/2) {
		throw("runqputslow: queue is not full")
	}
	for i := uint32(0); i < n; i++ {
		batch[i] = pp.runq[(h+i)%uint32(len(pp.runq))].ptr()
	}
	if !atomic.CasRel(&pp.runqhead, h, h+n) { // cas-release, commits consume
		return false
	}
	batch[n] = gp

	if randomizeScheduler { //打亂順序
		for i := uint32(1); i <= n; i++ {
			j := cheaprandn(i + 1)
			batch[i], batch[j] = batch[j], batch[i]
		}
	}

	// Link the goroutines.
	for i := uint32(0); i < n; i++ {
		batch[i].schedlink.set(batch[i+1])
	}
	var q gQueue
	q.head.set(batch[0])
	q.tail.set(batch[n])

	// Now put the batch on global queue.
	lock(&sched.lock)
	globrunqputbatch(&q, int32(n+1))
	unlock(&sched.lock)
	return true
}

其執行流程如下:

var batch [len(pp.runq)/2 + 1]*g //本地佇列一半的G

首先建立一個batch陣列,是容量為P的本地佇列當前含有的G的數量的一半,用於儲存將轉移的G。

n := t - h
	n = n / 2
	if n != uint32(len(pp.runq)/2) {
		throw("runqputslow: queue is not full")
	}
	for i := uint32(0); i < n; i++ {
		batch[i] = pp.runq[(h+i)%uint32(len(pp.runq))].ptr()
	}

接著,開始將本地佇列一半的G的指標,儲存在batch中。

if randomizeScheduler { //打亂順序
		for i := uint32(1); i <= n; i++ {
			j := cheaprandn(i + 1)
			batch[i], batch[j] = batch[j], batch[i]
		}
	}

然後會打亂batch中的順序,保證隨機性。

// Link the goroutines.
	for i := uint32(0); i < n; i++ {
		batch[i].schedlink.set(batch[i+1])
	}
	var q gQueue
	q.head.set(batch[0])
	q.tail.set(batch[n])

	// Now put the batch on global queue.
	lock(&sched.lock)
	globrunqputbatch(&q, int32(n+1))
	unlock(&sched.lock)
	return true

最後一部是將batch中的各個G用指標連線起來,轉換為連結串列的形式,並且連結在全域性佇列中。

runqput連線的流程較長,用下圖來概括:

(二)本地佇列獲取
// local runq
	if gp, inheritTime := runqget(pp); gp != nil {
		return gp, inheritTime, false
	}

假如不是第61次呼叫,findrunnable會嘗試從本地佇列中獲取一個G用於排程。我們來看runqget方法的執行。

// 從本地可執行佇列中獲取 g。
func runqget(pp *p) (gp *g, inheritTime bool) {
	// 如果有 runnext,則它是下一個要執行的 G。
	next := pp.runnext
    // 如果 runnext 非零且 CAS 操作失敗,它只能被另一個 P 竊取,因為其他 P 可以競爭將 runnext 設定為零,但只有當前 P 可以將其設定為非零。
	// 因此,如果 CAS 失敗,則無需重試該操作。
	if next != 0 && pp.runnext.cas(next, 0) {
		return next.ptr(), true
	}

	for {
		h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with other consumers
		t := pp.runqtail
		if t == h {
			return nil, false
		}
		gp := pp.runq[h%uint32(len(pp.runq))].ptr()
		if atomic.CasRel(&pp.runqhead, h, h+1) { // cas-release, commits consume
			return gp, false
		}
	}
}

假如可以獲取到P的runnext,則返回這一個G,否則就獲取本地佇列的頭部的G。

(三)全域性佇列獲取
// global runq
	if sched.runqsize != 0 {
		lock(&sched.lock)
		gp := globrunqget(pp, 0)
		unlock(&sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}

假如無法從本地佇列獲取到G,則說明了P的本地佇列為空,此時會嘗試從全域性佇列獲取G。呼叫了globrunqget方法從全域性佇列獲取G,注意此時因為設定了max為0表示不生效,該方法可能會從全域性佇列中獲取多個G放到P的本地佇列內。關於該方法的具體程式碼已經在(一)中講解。

(四)網路事件獲取
    //在正式的去偷取G之前,用非阻塞的方式檢查是否有就緒的網路協程,這是對netpoll的一個最佳化。
	if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 {
		if list, delta := netpoll(0); !list.empty() { // non-blocking
			gp := list.pop()
			injectglist(&list)
			netpollAdjustWaiters(delta)
			trace := traceAcquire()
			casgstatus(gp, _Gwaiting, _Grunnable)
			if trace.ok() {
				trace.GoUnpark(gp, 0)
				traceRelease(trace)
			}
			return gp, false, false
		}
	}

假如本地佇列和全域性佇列都沒有G可以獲取,此時我們將進入GMP排程模型的一個特殊機制:WorkStealing,即從其他的P排程器中偷取其本地佇列的G到自己的本地佇列中,這是GMP排程模型獨有的機制,可以更加充分地利用執行緒提高系統整體效率。

在此之前,會先嚐試用非阻塞的方式獲取準備就緒的網路協程,如果有則先執行網路協程。

為什麼在攜程的排程中,還要專門引入對網路協程事件的檢測?這一部分不應該解耦嗎?

這是我自己的一個思考,我認為這應該是Go的執行時的設計原則的一個方面體現。runtime的主要任務是負責協程排程資源管理,但是在實際應用中,網路事件的處理通常會和協程排程緊密關聯。Go使用非阻塞網路輪詢機制(netpoll)允許在有網路事件發生時能快速的喚醒和排程相應的協程去處理,在進行了一次本地佇列和全域性佇列的檢查後,進行一次網路協程的檢查能保證對網路I/O的快速響應。

(五)工作竊取
	if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
		if !mp.spinning {
			mp.becomeSpinning()
		}

		gp, inheritTime, tnow, w, newWork := stealWork(now)
		if gp != nil {
			// Successfully stole.
			return gp, inheritTime, false
		}
		//...
	}

當本地佇列和全域性佇列都沒有G時,此時會進行工作竊取機制,嘗試從其他排程器P中竊取G。

if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
		if !mp.spinning {
			mp.becomeSpinning()
		}

如果當前的自旋的M的數量<空閒的P的數量的一半,就會將當前M設定為自旋態。

gp, inheritTime, tnow, w, newWork := stealWork(now)
		if gp != nil {
			// Successfully stole.
			return gp, inheritTime, false
		}

呼叫stealWork進行竊取。


func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
	pp := getg().m.p.ptr()

	ranTimer := false

    //最多從其他P竊取4次任務
	const stealTries = 4
	for i := 0; i < stealTries; i++ {
        //在進行最後一次的遍歷前,優先檢查其他P的Timer佇列
		stealTimersOrRunNextG := i == stealTries-1
		//隨機生成遍歷起點
		for enum := stealOrder.start(cheaprand()); !enum.done(); enum.next() {
			//...
			p2 := allp[enum.position()]
			if pp == p2 {
				continue
			}

			
			//...

			//如果P是非空閒的,則嘗試竊取
			if !idlepMask.read(enum.position()) {
				if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
					return gp, false, now, pollUntil, ranTimer
				}
			}
		}
	}

	//如果在所有嘗試中均未找到可執行的 Goroutine 或 Timer,則返回 nil,並返回 pollUntil(下一次輪詢的時間)。
	return nil, false, now, pollUntil, ranTimer
}
const stealTries = 4
	for i := 0; i < stealTries; i++ {

當前P會嘗試從其他的P的本地佇列中進行竊取,最多會進行4次。

for enum := stealOrder.start(cheaprand()); !enum.done(); enum.next() {
			//...
			p2 := allp[enum.position()]
			if pp == p2 {
				continue
			}

			
			//...

			//如果P是非空閒的,則嘗試竊取
			if !idlepMask.read(enum.position()) {
				if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
					return gp, false, now, pollUntil, ranTimer
				}
			}
		}

使用runqsteal方法進行竊取。


//從p2偷去一半的工作到p中
func runqsteal(pp, p2 *p, stealRunNextG bool) *g {
	t := pp.runqtail
	n := runqgrab(p2, &pp.runq, t, stealRunNextG)
	if n == 0 {
		return nil
	}
	n--
	gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr()
	if n == 0 {
		return gp
	}
	h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with consumers
	if t-h+n >= uint32(len(pp.runq)) {
		throw("runqsteal: runq overflow")
	}
	atomic.StoreRel(&pp.runqtail, t+n) // store-release, makes the item available for consumption
	return gp
}

runqsteal方法會將p2的本地佇列中偷取其一半的G放到p的本地佇列中,我們進而跟進runqgrab方法;


func runqgrab(pp *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 {
	for {
		h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with other consumers
		t := atomic.LoadAcq(&pp.runqtail) // load-acquire, synchronize with the producer
		n := t - h
		n = n - n/2
		if n == 0 {
			if stealRunNextG {
				//嘗試偷取P的下一個將要排程的G
				if next := pp.runnext; next != 0 {
                    //如果P正在執行,為了避免產生頻繁的任務狀態“抖動”,互相搶佔任務導致的排程競爭,所以休眠一會,等待P排程完成再嘗試獲取。
					if pp.status == _Prunning {
						if !osHasLowResTimer {
							usleep(3)
						} else {
							osyield()
						}
					}
                    //嘗試竊取任務
					if !pp.runnext.cas(next, 0) {
						continue
					}
                    //竊取成功
					batch[batchHead%uint32(len(batch))] = next
					return 1
				}
			}
			return 0
		}
        //如果n超過佇列一半,則由於併發訪問導致h和t不一致,要重新開始。
		if n > uint32(len(pp.runq)/2) { // read inconsistent h and t
			continue
		}
        //從runq批次抓取任務
		for i := uint32(0); i < n; i++ {
			g := pp.runq[(h+i)%uint32(len(pp.runq))]
			batch[(batchHead+i)%uint32(len(batch))] = g
		}
		if atomic.CasRel(&pp.runqhead, h, h+n) { // cas-release, commits consume
			return n
		}
	}
}

n=n-n/2我們可以得知,是獲取一半數量的G。

透過stealWork->runqsteal->runqgrab的方法鏈路,完成了將其他P的本地佇列G搬運到當前P的本地佇列中的過程。

(六)總覽

最後,我們用繪圖來整體回顧findRunnable的執行流程。

2.3.2、execute()

當我們成功的透過findRunnable()找到了可以被執行的G的時候,就會對當前的G呼叫execute()方法,開始去呼叫這個G。

func execute(gp *g, inheritTime bool) {
	mp := getg().m


	//繫結G和M
	mp.curg = gp
	gp.m = mp
    //更改G的狀態
	casgstatus(gp, _Grunnable, _Grunning)
	gp.waitsince = 0
	gp.preempt = false
	gp.stackguard0 = gp.stack.lo + stackGuard
	if !inheritTime {
        //更新P的排程次數
		mp.p.ptr().schedtick++
	}
	//....
	//執行G的任務
	gogo(&gp.sched)
}

可以看到execute的主要任務就是將當前的G和M進行繫結,即把G分配給這個執行緒M,然後調整它的狀態為執行態,最後呼叫gogo方法完成對使用者方法的執行。

2.3.3、mcall()

從2.3.2小節中我們知道,執行的execute函式完成了g0和g的切換,將對M的執行權交給了g,然後呼叫了gogo方法執行g。當需要重新將M的執行權從g切換到g0的時候,需要執行mcall()方法,完成切換。mcall()方法的作用我們在2.1小節中提到過,該方法是透過組合語言實現的,主要的作用是完成了對g的棧資訊的儲存、將當前堆疊從g切換到g0、在g0的棧上執行mcall方法中傳入的fn回撥函式。

什麼時候呼叫mcall(),就涉及到我們在2.2小節講到了排程型別了。接下來我們透過原始碼一一分析。

1、主動排程

主動排程是提供給使用者的讓權方法,執行的是runtime包下的Gosched方法。

func Gosched() {
	checkTimeouts()
	mcall(gosched_m)
}

Gosched方法就呼叫了mcall,並且傳入回撥函式gosched_m

// Gosched continuation on g0.
func gosched_m(gp *g) {
	goschedImpl(gp, false)
}

func goschedImpl(gp *g, preempted bool) {
	//...
	casgstatus(gp, _Grunning, _Grunnable)// 將Goroutine狀態從執行中更改為可執行狀態
	//...

	dropg()//解綁G和M
	lock(&sched.lock)
	globrunqput(gp)//將G放入到全域性佇列中,等待下一次的排程
	unlock(&sched.lock)

	//...

	schedule()// 呼叫排程器,從全域性佇列或本地佇列選擇下一個Goroutine執行
}

gosched_m完成了對G的狀態的轉換,然後呼叫dropg將M和G解綁,再將G放回到全域性佇列裡面,最終呼叫schedule進行新一輪的排程。

2、被動排程

噹噹前G需要被被動呼叫的時候,就會呼叫goprak(),將其置為阻塞態,等待別人的喚醒。

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceReason traceBlockReason, traceskip int) {
	//...
	mcall(park_m)
}

// park continuation on g0.
func park_m(gp *g) {
	mp := getg().m

	trace := traceAcquire()

	casgstatus(gp, _Grunning, _Gwaiting)
	//...

	dropg()

	//...
	schedule()
}

gopark內部呼叫了mcall(park_m)park_m將G的狀態置為waiting,並且將M和G解綁,然後開啟新一輪的排程。

進入等待的G需要被動的被其他事件喚醒,此時就會呼叫goready方法。

func goready(gp *g, traceskip int) {
	systemstack(func() {
		ready(gp, traceskip, true)
	})
}


//ready 函式的作用是將指定的 Goroutine (gp) 標記為“可執行”狀態並將其放入執行佇列。它會在 Goroutine 從等待(_Gwaiting)狀態轉換為可執行(_Grunnable)狀態時使用,以確保排程器能夠選擇並執行它。
// Mark gp ready to run.
func ready(gp *g, traceskip int, next bool) {
	status := readgstatus(gp)

	// Mark runnable.
	mp := acquirem() // 獲取當前執行緒(M),並禁止其被搶佔,以避免將 P 錯誤地保留在本地變數中。
    //確認G的狀態
	if status&^_Gscan != _Gwaiting {
		dumpgstatus(gp)
		throw("bad g->status in ready")
	}
	//...
	casgstatus(gp, _Gwaiting, _Grunnable)
	//....
    //將該G放入到當前P的執行佇列
	runqput(mp.p.ptr(), gp, next)
    //檢查是否有空閒的 P,若有則喚醒,以便它能夠處理新加入的可執行 Goroutine。
	wakep()
    //釋放當前 M 的鎖,以重新允許搶佔。
	releasem(mp)
}

ready方法會將G的狀態重新切換成執行態,並且將G放入到P的執行佇列裡面。從程式碼中我們可以看到,被喚醒的G並不會立刻執行,而是加入到本地佇列中等待下一次被排程。

3、正常排程

假如G被正常的執行完畢,就會呼叫goexit1()方法完成g和g0的切換。

func goexit1() {
	//...
	mcall(goexit0)
}


// goexit continuation on g0.
func goexit0(gp *g) {
	gdestroy(gp)
	schedule()
}

最終,協程G被銷燬,並且開啟新一輪的排程。

4、搶佔排程

搶佔排程最為複雜,因為它需要全域性監控者m去檢查所有的P是否被長期阻塞,這需要花時間去檢索,而不能直接鎖定到哪個P需要被搶佔。全域性監控者會呼叫retake()方法去檢查,其流程如下:

//retake 函式用於在 Go 的排程器中處理一些排程策略,確保 Goroutine 的執行不被長時間阻塞。它透過檢查所有的處理器 (P),嘗試中斷過長的系統呼叫並在合適的條件下重新奪回 P 的控制權。
func retake(now int64) uint32 {
	n := 0
	lock(&allpLock)
	for i := 0; i < len(allp); i++ {
		pp := allp[i]
		if pp == nil {
			continue
		}
		pd := &pp.sysmontick
		s := pp.status
		sysretake := false
		if s == _Prunning || s == _Psyscall {
            //// 如果 `P` 的狀態為 `_Prunning` 或 `_Psyscall`,則檢查其執行時長。
			t := int64(pp.schedtick)
			if int64(pd.schedtick) != t {
				pd.schedtick = uint32(t)
				pd.schedwhen = now
			} else if pd.schedwhen+forcePreemptNS <= now {
                //超過最大執行時間,搶佔P
				preemptone(pp)
				//如果處於系統呼叫狀態,`preemptone()` 無法中斷 P,因為沒有 M 繫結到 P。
				sysretake = true
			}
		}
		if s == _Psyscall {
			// 如果 `P` 在系統呼叫中停留超過 1 個監控週期,則嘗試收回。
			t := int64(pp.syscalltick)
			if !sysretake && int64(pd.syscalltick) != t {
				pd.syscalltick = uint32(t)
				pd.syscallwhen = now
				continue
			}
            //如果當前P的執行佇列為空,切存在至少一個自旋的M,並且未超出等待時間則跳過回收
			if runqempty(pp) && sched.nmspinning.Load()+sched.npidle.Load() > 0 && pd.syscallwhen+10*1000*1000 > now {
				continue
			}
			// 為了獲取 `sched.lock`,先釋放 `allpLock`
			unlock(&allpLock)
			
            //回收操作...
            handoffp(pp)
		}
	}
	unlock(&allpLock)
	return uint32(n)
}
for i := 0; i < len(allp); i++ {
		pp := allp[i]
		if pp == nil {
			continue
		}

逐一的獲取P,進行檢查。

if s == _Prunning || s == _Psyscall {
            //// 如果 `P` 的狀態為 `_Prunning` 或 `_Psyscall`,則檢查其執行時長。
			t := int64(pp.schedtick)
			if int64(pd.schedtick) != t {
				pd.schedtick = uint32(t)
				pd.schedwhen = now
			} else if pd.schedwhen+forcePreemptNS <= now {
                //超過最大執行時間,搶佔P
				preemptone(pp)
				//如果處於系統呼叫狀態,`preemptone()` 無法中斷 P,因為沒有 M 繫結到 P。
				sysretake = true
			}
		}

當P的執行時間超過最大執行時間的時候,就會呼叫preemptone方法,嘗試去搶佔P。

值得注意的地方是,preemptone方法是設計成“盡力而為”的,因為併發的存在,我們並不能確保它一定能通知到我們需要解綁的G,因為可能會存在以下狀況:

  • 當我們嘗試去發出搶佔通知P上的G需要停止執行的時候,可能在發出通知的過程,這個G就完成執行,呼叫到下一個G了,我們可能會通知了錯誤的G。
  • 當G進入到系統呼叫的狀態的時候,P和M就會解綁,我們也通知不到G了。
  • 就算通知到了目標的G,它也可能在執行newstack,此時會忽略請求。

因此,preemptone方法只會嘗試在自己未和M解綁以及m上的g此時不是g0的情況下,將gp.preempt置為true,表示發出了通知便返回true。具體的搶佔將可能會在未來的某一時刻發生。

if s == _Psyscall {
			// 如果 `P` 在系統呼叫中停留超過 1 個監控週期,則嘗試收回。
			t := int64(pp.syscalltick)
			if !sysretake && int64(pd.syscalltick) != t {
				pd.syscalltick = uint32(t)
				pd.syscallwhen = now
				continue
			}
            //如果當前P的執行佇列為空,切存在至少一個自旋的M,並且未超出等待時間則跳過回收
			if runqempty(pp) && sched.nmspinning.Load()+sched.npidle.Load() > 0 && pd.syscallwhen+10*1000*1000 > now {
				continue
			}
			// 為了獲取 `sched.lock`,先釋放 `allpLock`
			unlock(&allpLock)
			
            //回收操作...
    if atomic.Cas(&pp.status, s, _Pidle) {
        //....
        	handoffp(pp)
    }

		}

當滿足以下三個條件的時候,就會執行搶佔排程:

  • p的本地佇列有等待執行的G
  • 當前沒有空閒的p和m
  • 執行系統呼叫的時間超過10ms

此時就會呼叫搶佔排程,先將p的狀態置為idle,表示可以被其他的M獲取繫結,然後呼叫handoffp方法。

func handoffp(pp *p) {
	// handoffp must start an M in any situation where
	// findrunnable would return a G to run on pp.

	// if it has local work, start it straight away
	if !runqempty(pp) || sched.runqsize != 0 {
		startm(pp, false, false)
		return
	}
	// if there's trace work to do, start it straight away
	if (traceEnabled() || traceShuttingDown()) && traceReaderAvailable() != nil {
		startm(pp, false, false)
		return
	}
	// if it has GC work, start it straight away
	if gcBlackenEnabled != 0 && gcMarkWorkAvailable(pp) {
		startm(pp, false, false)
		return
	}
	// no local work, check that there are no spinning/idle M's,
	// otherwise our help is not required
	if sched.nmspinning.Load()+sched.npidle.Load() == 0 && sched.nmspinning.CompareAndSwap(0, 1) { // TODO: fast atomic
		sched.needspinning.Store(0)
		startm(pp, true, false)
		return
	}
	lock(&sched.lock)
	if sched.gcwaiting.Load() {
		pp.status = _Pgcstop
		sched.stopwait--
		if sched.stopwait == 0 {
			notewakeup(&sched.stopnote)
		}
		unlock(&sched.lock)
		return
	}
	if pp.runSafePointFn != 0 && atomic.Cas(&pp.runSafePointFn, 1, 0) {
		sched.safePointFn(pp)
		sched.safePointWait--
		if sched.safePointWait == 0 {
			notewakeup(&sched.safePointNote)
		}
	}
	if sched.runqsize != 0 {
		unlock(&sched.lock)
		startm(pp, false, false)
		return
	}
	// If this is the last running P and nobody is polling network,
	// need to wakeup another M to poll network.
	if sched.npidle.Load() == gomaxprocs-1 && sched.lastpoll.Load() != 0 {
		unlock(&sched.lock)
		startm(pp, false, false)
		return
	}

	// The scheduler lock cannot be held when calling wakeNetPoller below
	// because wakeNetPoller may call wakep which may call startm.
	when := nobarrierWakeTime(pp)
	pidleput(pp, 0)
	unlock(&sched.lock)

	if when != 0 {
		wakeNetPoller(when)
	}
}

當我們滿足以下情況之一的時候,就會為當前的P新分配一個M進行排程:

  • 全域性佇列不為空或者本地佇列不為空,即有可以執行的G。
  • 需要有trace去執行。
  • 有垃圾回收的工作需要執行。
  • 當前時刻沒有自旋的執行緒M並且沒有空閒的P(表示當前時刻任務繁忙)。
  • 當前P是唯一在執行的P,並且有網路事件等待處理。

當滿足五個條件之一的時候,都會進入到startm()方法中,為當前的P分配一個M。


func startm(pp *p, spinning, lockheld bool) {
	mp := acquirem()
	if !lockheld {
		lock(&sched.lock)
	}
	if pp == nil {
		if spinning {
		}
		pp, _ = pidleget(0)
		if pp == nil {
			if !lockheld {
				unlock(&sched.lock)
			}
			releasem(mp)
			return
		}
	}
	nmp := mget()
	if nmp == nil {
		id := mReserveID()
		unlock(&sched.lock)

		var fn func()
		if spinning {
			fn = mspinning
		}
		newm(fn, pp, id)

		if lockheld {
			lock(&sched.lock)
		}
		releasem(mp)
		return
	}
	//...
	releasem(mp)
}
if pp == nil {
		if spinning {
		}
		pp, _ = pidleget(0)
		if pp == nil {
			if !lockheld {
				unlock(&sched.lock)
			}
			releasem(mp)
			return
		}
	}

假如傳入的pp是nil,那麼會自動設定為空閒p佇列中的第一個p,假如仍然為nil表示當前沒有空閒的p,會退出方法。

nmp := mget()
	if nmp == nil {
		id := mReserveID()
		unlock(&sched.lock)

		var fn func()
		if spinning {
			fn = mspinning
		}
		newm(fn, pp, id)

		if lockheld {
			lock(&sched.lock)
		}
		releasem(mp)
		return
	}

然後會嘗試獲取當前的空閒的m,假如不存在則新建立一個m。

至此,關於GMP模型的節選部分的講解就完成了,可能有許多我理解的不對的地方歡迎大家討論,謝謝觀看。

相關文章