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