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模型的節選部分的講解就完成了,可能有許多我理解的不對的地方歡迎大家討論,謝謝觀看。