嗯,你覺得 Go 在什麼時候會搶佔 P?
大家好,我是煎魚。
昨天剛從長沙浪完回來,準備 ” 瀟瀟灑灑 “ 寫一篇遊記,分享一波美食 + 遊記,有五一準備去長沙玩的小夥伴嗎?
前幾天我們有聊到《單核 CPU,開兩個 Goroutine,其中一個死循環,會怎麼樣?》的問題,我們在一個細節部分有提到:
有新的小夥伴會產生更多的疑問,那就是在 Go 語言中,是如何搶佔 P 的呢,這裏面是怎麼做的?
今天這篇文章我們就來解密搶佔 P。
調度器的發展史
在 Go 語言中,Goroutine 早期是沒有設計成搶佔式的,早期 Goroutine 只有讀寫、主動讓出、鎖等操作時纔會觸發調度切換。
這樣有一個嚴重的問題,就是垃圾回收器進行 STW 時,如果有一個 Goroutine 一直都在阻塞調用,垃圾回收器就會一直等待他,不知道等到什麼時候...
這種情況下就需要搶佔式調度來解決問題。如果一個 Goroutine 運行時間過久,就需要進行搶佔來解決。
這塊 Go 語言在 Go1.2 起開始實現搶佔式調度器,不斷完善直至今日:
-
Go0.x:基於單線程的程調度器。
-
Go1.0:基於多線程的調度器。
-
Go1.1:基於任務竊取的調度器。
-
Go1.2 - Go1.13:基於協作的搶佔式調度器。
-
Go1.14:基於信號的搶佔式調度器。
調度器的新提案:非均勻存儲器訪問調度(Non-uniform memory access,NUMA), 但由於實現過於複雜,優先級也不夠高,因此遲遲未提上日程。
有興趣的小夥伴可以詳見 Dmitry Vyukov, dvyukov 所提出的 NUMA-aware scheduler for Go。
爲什麼要搶佔 P
爲什麼會要想去搶佔 P 呢,說白了就是不搶,就沒機會運行,會 hang 死。又或是資源分配不均了,
這在調度器設計中顯然是不合理的。
跟這個例子一樣:
// Main Goroutine
func main() {
// 模擬單核 CPU
runtime.GOMAXPROCS(1)
// 模擬 Goroutine 死循環
go func() {
for {
}
}()
time.Sleep(time.Millisecond)
fmt.Println("腦子進煎魚了")
}
這個例子在老版本的 Go 語言中,就會一直阻塞,沒法重見天日,是一個需要做搶佔的場景。
但可能會有小夥伴問,搶佔了,會不會有新問題。因爲原本正在使用 P 的 M 就涼涼了(M 會與 P 進行綁定),沒了 P 也就沒法繼續執行了。
這其實沒有問題,因爲該 Goroutine 已經阻塞在了系統調用上,暫時是不會有後續的執行新訴求。
但萬一代碼是在運行了好一段時間後又能夠運行了(業務上也允許長等待),也就是該 Goroutine 從阻塞狀態中恢復了,期望繼續運行,沒了 P 怎麼辦?
這時候該 Goroutine 可以和其他 Goroutine 一樣,先檢查自身所在的 M 是否仍然綁定着 P:
-
若是有 P,則可以調整狀態,繼續運行。
-
若是沒有 P,可以重新搶 P,再佔有並綁定 P,爲自己所用。
也就是搶佔 P,本身就是一個雙向行爲,你搶了我的 P,我也可以去搶別人的 P 來繼續運行。
怎麼搶佔 P
講解了爲什麼要搶佔 P 的原因後,我們進一步深挖,“他” 是怎麼搶佔到具體的 P 的呢?
這就涉及到前文所提到的 runtime.retake
方法了,其處理以下兩種場景:
-
搶佔阻塞在系統調用上的 P。
-
搶佔運行時間過長的 G。
在此主要針對搶佔 P 的場景,分析如下:
func retake(now int64) uint32 {
n := 0
// 防止發生變更,對所有 P 加鎖
lock(&allpLock)
// 走入主邏輯,對所有 P 開始循環處理
for i := 0; i < len(allp); i++ {
_p_ := allp[i]
pd := &_p_.sysmontick
s := _p_.status
sysretake := false
...
if s == _Psyscall {
// 判斷是否超過 1 個 sysmon tick 週期
t := int64(_p_.syscalltick)
if !sysretake && int64(pd.syscalltick) != t {
pd.syscalltick = uint32(t)
pd.syscallwhen = now
continue
}
...
}
}
unlock(&allpLock)
return uint32(n)
}
該方法會先對 allpLock
上鎖,這個變量含義如其名,allpLock
可以防止該數組發生變化。
其會保護 allp
、idlepMask
和 timerpMask
屬性的無 P
讀取和大小變化,以及對 allp
的所有寫入操作,可以避免影響後續的操作。
場景一
前置處理完畢後,進入主邏輯,會使用萬能的 for
循環對所有的 P(allp)進行一個個處理。
t := int64(_p_.syscalltick)
if !sysretake && int64(pd.syscalltick) != t {
pd.syscalltick = uint32(t)
pd.syscallwhen = now
continue
}
第一個場景是:會對 syscalltick
進行判定,如果在系統調用(syscall)中存在超過 1 個 sysmon tick 週期(至少 20us)的任務,則會從系統調用中搶佔 P,否則跳過。
場景二
如果未滿足會繼續往下,走到如下邏輯:
func retake(now int64) uint32 {
for i := 0; i < len(allp); i++ {
...
if s == _Psyscall {
// 從此處開始分析
if runqempty(_p_) &&
atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 &&
pd.syscallwhen+10*1000*1000 > now {
continue
}
...
}
}
unlock(&allpLock)
return uint32(n)
}
第二個場景,聚焦到這一長串的判斷中:
-
runqempty(_p_) == true
方法會判斷任務隊列 P 是否爲空,以此來檢測有沒有其他任務需要執行。 -
atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0
會判斷是否存在空閒 P 和正在進行調度竊取 G 的 P。 -
pd.syscallwhen+10*1000*1000 > now
會判斷系統調用時間是否超過了 10ms。
這裏奇怪的是 runqempty
方法明明已經判斷了沒有其他任務,這就代表了沒有任務需要執行,是不需要搶奪 P 的。
但實際情況是,由於可能會阻止 sysmon 線程的深度睡眠,最終還是希望繼續佔有 P。
在完成上述判斷後,進入到搶奪 P 的階段:
func retake(now int64) uint32 {
for i := 0; i < len(allp); i++ {
...
if s == _Psyscall {
// 承接上半部分
unlock(&allpLock)
incidlelocked(-1)
if atomic.Cas(&_p_.status, s, _Pidle) {
if trace.enabled {
traceGoSysBlock(_p_)
traceProcStop(_p_)
}
n++
_p_.syscalltick++
handoffp(_p_)
}
incidlelocked(1)
lock(&allpLock)
}
}
unlock(&allpLock)
return uint32(n)
}
-
解鎖相關屬性:需要調用
unlock
方法解鎖allpLock
,從而實現獲取sched.lock
,以便繼續下一步。 -
減少閒置 M:需要在原子操作(CAS)之前減少閒置 M 的數量(假設有一個正在運行)。否則在發生搶奪 M 時可能會退出系統調用,遞增 nmidle 並報告死鎖事件。
-
修改 P 狀態:調用
atomic.Cas
方法將所搶奪的 P 狀態設爲 idle,以便於交於其他 M 使用。 -
搶奪 P 和調控 M:調用
handoffp
方法從系統調用或鎖定的 M 中搶奪 P,會由新的 M 接管這個 P。
總結
至此完成了搶佔 P 的基本流程,我們可得出滿足以下條件:
-
如果存在系統調用超時:存在超過 1 個 sysmon tick 週期(至少 20us)的任務,則會從系統調用中搶佔 P。
-
如果沒有空閒的 P:所有的 P 都已經與 M 綁定。需要搶佔當前正處於系統調用之,而實際上系統調用並不需要的這個 P 的情況,會將其分配給其它 M 去調度其它 G。
-
如果 P 的運行隊列裏面有等待運行的 G,爲了保證 P 的本地隊列中的 G 得到及時調度。而自己本身的 P 又忙於系統調用,無暇管理。此時會尋找另外一個 M 來接管 P,從而實現繼續調度 G 的目的。
參考
-
NUMA-aware scheduler for Go
-
go-under-the-hood
-
深入解析 Go - 搶佔式調度
-
Go 語言調度器源代碼情景分析
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/WAPogwLJ2BZvrquoKTQXzg