您好,登錄后才能下訂單哦!
這篇文章主要介紹“分析Go協作與搶占”,在日常操作中,相信很多人在分析Go協作與搶占問題上存在疑惑,小編查閱了各式資料,整理出簡單好用的操作方法,希望對大家解答”分析Go協作與搶占”的疑惑有所幫助!接下來,請跟著小編一起來學習吧!
協作式調度
主動用戶讓權:Gosched
Gosched 是一種主動放棄執行的手段,用戶態代碼通過調用此接口來出讓執行機會,使其他“人”也能在密集的執行過程中獲得被調度的機會。
Gosched 的實現非常簡單:
// Gosched 會讓出當前的 P,并允許其他 Goroutine 運行。 // 它不會推遲當前的 Goroutine,因此執行會被自動恢復 func Gosched() { checkTimeouts() mcall(gosched_m) } // Gosched 在 g0 上繼續執行 func gosched_m(gp *g) { ... goschedImpl(gp) }
它首先會通過 note 機制通知那些等待被 ready 的 Goroutine:
// checkTimeouts 恢復那些在等待一個 note 且已經觸發其 deadline 時的 Goroutine。 func checkTimeouts() { now := nanotime() for n, nt := range notesWithTimeout { if n.key == note_cleared && now > nt.deadline { n.key = note_timeout goready(nt.gp, 1) } } } func goready(gp *g, traceskip int) { systemstack(func() { ready(gp, traceskip, true) }) } // 將 gp 標記為 ready 來運行 func ready(gp *g, traceskip int, next bool) { if trace.enabled { traceGoUnpark(gp, traceskip) } status := readgstatus(gp) // 標記為 runnable. _g_ := getg() _g_.m.locks++ // 禁止搶占,因為它可以在局部變量中保存 p if status&^_Gscan != _Gwaiting { dumpgstatus(gp) throw("bad g->status in ready") } // 狀態為 Gwaiting 或 Gscanwaiting, 標記 Grunnable 并將其放入運行隊列 runq casgstatus(gp, _Gwaiting, _Grunnable) runqput(_g_.m.p.ptr(), gp, next) if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 { wakep() } _g_.m.locks-- if _g_.m.locks == 0 && _g_.preempt { // 在 newstack 中已經清除它的情況下恢復搶占請求 _g_.stackguard0 = stackPreempt } } func notetsleepg(n *note, ns int64) bool { gp := getg() ... if ns >= 0 { deadline := nanotime() + ns ... notesWithTimeout[n] = noteWithTimeout{gp: gp, deadline: deadline} ... gopark(nil, nil, waitReasonSleep, traceEvNone, 1) ... delete(notesWithTimeout, n) ... } ... }
而后通過 mcall 調用 gosched_m 在 g0 上繼續執行并讓出 P,實質上是讓 G 放棄當前在 M 上的執行權利,M 轉去執行其他的 G,并在上下文切換時候,將自身放入全局隊列等待后續調度:
func goschedImpl(gp *g) { // 放棄當前 g 的運行狀態 status := readgstatus(gp) ... casgstatus(gp, _Grunning, _Grunnable) // 使當前 m 放棄 g dropg() // 并將 g 放回全局隊列中 lock(&sched.lock) globrunqput(gp) unlock(&sched.lock) // 重新進入調度循環 schedule() }
當然,盡管具有主動棄權的能力,但它對 Go 語言的用戶要求比較高,因為用戶在編寫并發邏輯的時候需要自行甄別是否需要讓出時間片,這并非用戶友好的,而且很多 Go 的新用戶并不會了解到這個問題的存在,我們在隨后的搶占式調度中再進一步展開討論。
主動調度棄權:棧擴張與搶占標記
另一種主動放棄的方式是通過搶占標記的方式實現的。基本想法是在每個函數調用的序言(函數調用的最前方)插入搶占檢測指令,當檢測到當前 Goroutine 被標記為應該被搶占時,則主動中斷執行,讓出執行權利。表面上看起來想法很簡單,但實施起來就比較復雜了。
在 6.6 執行棧管理[2] 一節中我們已經了解到,函數調用的序言部分會檢查 SP 寄存器與 stackguard0 之間的大小,如果 SP 小于 stackguard0 則會觸發 morestack_noctxt,觸發棧分段操作。換言之,如果搶占標記將 stackgard0 設為比所有可能的 SP 都要大(即 stackPreempt),則會觸發 morestack,進而調用 newstack:
// Goroutine 搶占請求 // 存儲到 g.stackguard0 來導致棧分段檢查失敗 // 必須比任何實際的 SP 都要大 // 十六進制為:0xfffffade const stackPreempt = (1<<(8*sys.PtrSize) - 1) & -1314
從搶占調度的角度來看,這種發生在函數序言部分的搶占的一個重要目的就是能夠簡單且安全的記錄執行現場(隨后的搶占式調度我們會看到記錄執行現場給采用信號方式中斷線程執行的調度帶來多大的困難)。事實也是如此,在 morestack 調用中:
TEXT runtime·morestack(SB),NOSPLIT,$0-0 ... MOVQ 0(SP), AX // f's PC MOVQ AX, (g_sched+gobuf_pc)(SI) MOVQ SI, (g_sched+gobuf_g)(SI) LEAQ 8(SP), AX // f's SP MOVQ AX, (g_sched+gobuf_sp)(SI) MOVQ BP, (g_sched+gobuf_bp)(SI) MOVQ DX, (g_sched+gobuf_ctxt)(SI) ... CALL runtime·newstack(SB)
是有記錄 Goroutine 的 PC 和 SP 寄存器,而后才開始調用 newstack 的:
//go:nowritebarrierrec func newstack() { thisg := getg() ... gp := thisg.m.curg ... morebuf := thisg.m.morebuf thisg.m.morebuf.pc = 0 thisg.m.morebuf.lr = 0 thisg.m.morebuf.sp = 0 thisg.m.morebuf.g = 0 // 如果是發起的搶占請求而非真正的棧分段 preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt // 保守的對用戶態代碼進行搶占,而非搶占運行時代碼 // 如果正持有鎖、分配內存或搶占被禁用,則不發生搶占 if preempt { if !canPreemptM(thisg.m) { // 不發生搶占,繼續調度 gp.stackguard0 = gp.stack.lo + _StackGuard gogo(&gp.sched) // 重新進入調度循環 } } ... // 如果需要對棧進行調整 if preempt { ... if gp.preemptShrink { // 我們正在一個同步安全點,因此等待棧收縮 gp.preemptShrink = false shrinkstack(gp) } if gp.preemptStop { preemptPark(gp) // 永不返回 } ... // 表現得像是調用了 runtime.Gosched,主動讓權 gopreempt_m(gp) // 重新進入調度循環 } ... } // 與 gosched_m 一致 func gopreempt_m(gp *g) { ... goschedImpl(gp) }
其中的 canPreemptM 驗證了可以被搶占的條件:
運行時沒有禁止搶占(m.locks == 0)
運行時沒有在執行內存分配(m.mallocing == 0)
運行時沒有關閉搶占機制(m.preemptoff == "")
M 與 P 綁定且沒有進入系統調用(p.status == _Prunning)
// canPreemptM 報告 mp 是否處于可搶占的安全狀態。 //go:nosplit func canPreemptM(mp *m) bool { return mp.locks == 0 && mp.mallocing == 0 && mp.preemptoff == "" && mp.p.ptr().status == _Prunning }
從可被搶占的條件來看,能夠對一個 G 進行搶占其實是呈保守狀態的。這一保守體現在搶占對很多運行時所需的條件進行了判斷,這也理所當然是因為運行時優先級更高,不應該輕易發生搶占,但與此同時由于又需要對用戶態代碼進行搶占,于是先作出一次不需要搶占的判斷(快速路徑),確定不能搶占時返回并繼續調度,如果真的需要進行搶占,則轉入調用 gopreempt_m,放棄當前 G 的執行權,將其加入全局隊列,重新進入調度循環。
什么時候會給 stackguard0 設置搶占標記 stackPreempt 呢?一共有以下幾種情況:
進入系統調用時(runtime.reentersyscall,注意這種情況是為了保證不會發生棧分裂,真正的搶占是異步地通過系統監控進行的)
任何運行時不再持有鎖的時候(m.locks == 0)
當垃圾回收器需要停止所有用戶 Goroutine 時
搶占式調度
從上面提到的兩種協作式調度邏輯我們可以看出,這種需要用戶代碼來主動配合的調度方式存在一些致命的缺陷:一個沒有主動放棄執行權、且不參與任何函數調用的函數,直到執行完畢之前,是不會被搶占的。
那么這種不會被搶占的函數會導致什么嚴重的問題呢?回答是,由于運行時無法停止該用戶代碼,則當需要進行垃圾回收時,無法及時進行;對于一些實時性要求較高的用戶態 Goroutine 而言,也久久得不到調度。我們這里不去深入討論垃圾回收的具體細節,讀者將在垃圾回收器[3]一章中詳細看到這類問題導致的后果。單從調度的角度而言,我們直接來看一個非常簡單的例子:
// 此程序在 Go 1.14 之前的版本不會輸出 OK package main import ( "runtime" "time" ) func main() { runtime.GOMAXPROCS(1) go func() { for { } }() time.Sleep(time.Millisecond) println("OK") }
這段代碼中處于死循環的 Goroutine 永遠無法被搶占,其中創建的 Goroutine 會執行一個不產生任何調用、不主動放棄執行權的死循環。由于主 Goroutine 優先調用了休眠,此時唯一的 P 會轉去執行 for 循環所創建的 Goroutine。進而主 Goroutine 永遠不會再被調度,進而程序徹底阻塞在了這個 Goroutine 上,永遠無法退出。這樣的例子非常多,但追根溯源,均為此問題導致。
Go 團隊其實很早(1.0 以前)就已經意識到了這個問題,但在 Go 1.2 時增加了上文提到的在函數序言部分增加搶占標記后,此問題便被擱置,直到越來越多的用戶提交并報告此問題。在 Go 1.5 前后,Austin Clements 希望僅解決這種由密集循環導致的無法搶占的問題 [Clements, 2015],于是嘗試通過協作式 loop 循環搶占,通過編譯器輔助的方式,插入搶占檢查指令,與流程圖回邊(指節點被訪問過但其子節點尚未訪問完畢)安全點(在一個線程執行中,垃圾回收器能夠識別所有對象引用狀態的一個狀態)的方式進行解決。
盡管此舉能為搶占帶來顯著的提升,但是在一個循環中引入分支顯然會降低性能。盡管隨后 David Chase 對這個方法進行了改進,僅在插入了一條 TESTB 指令 [Chase, 2017],在完全沒有分支以及寄存器壓力的情況下,仍然造成了幾何平均 7.8% 的性能損失。這種結果其實是情理之中的,很多需要進行密集循環的計算時間都是在運行時才能確定的,直接由編譯器檢測這類密集循環而插入額外的指令可想而知是欠妥的做法。
終于在 Go 1.10 后 [Clements, 2019],Austin 進一步提出的解決方案,希望使用每個指令與執行棧和寄存器的映射關系,通過記錄足夠多的信息,并通過異步線程來發送搶占信號的方式來支持異步搶占式調度。
我們知道現代操作系統的調度器多為搶占式調度,其實現方式通過硬件中斷來支持線程的切換,進而能安全的保存運行上下文。在 Go 運行時實現搶占式調度同樣也可以使用類似的方式,通過向線程發送系統信號的方式來中斷 M 的執行,進而達到搶占的目的。但與操作系統的不同之處在于,由于運行時諸多機制的存在(例如垃圾回收器),還必須能夠在 Goroutine 被停止時,保存充足的上下文信息(見 8.9 安全點分析[4])。這就給中斷信號帶來了麻煩,如果中斷信號恰好發生在一些關鍵階段(例如寫屏障期間),則無法保證程序的正確性。這也就要求我們需要嚴格考慮觸發異步搶占的時機。
異步搶占式調度的一種方式就與運行時系統監控有關,監控循環會將發生阻塞的 Goroutine 搶占,解綁 P 與 M,從而讓其他的線程能夠獲得 P 繼續執行其他的 Goroutine。這得益于 sysmon中調用的 retake 方法。這個方法處理了兩種搶占情況,一是搶占阻塞在系統調用上的 P,二是搶占運行時間過長的 G。其中搶占運行時間過長的 G 這一方式還會出現在垃圾回收需要進入 STW 時。
P 搶占
我們先來看搶占阻塞在系統調用上的 G 這種情況。這種搶占的實現方法非常的自然,因為 Goroutine 已經阻塞在了系統調用上,我們可以非常安全的將 M 與 P 進行解綁,即便是 Goroutine 從阻塞中恢復,也會檢查自身所在的 M 是否仍然持有 P,如果沒有 P 則重新考慮與可用的 P 進行綁定。這種異步搶占的本質是:搶占 P。
unc retake(now int64) uint32 { n := 0 // 防止 allp 數組發生變化,除非我們已經 STW,此鎖將完全沒有人競爭 lock(&allpLock) for i := 0; i < len(allp); i++ { _p_ := allp[i] ... pd := &_p_.sysmontick s := _p_.status sysretake := false if s == _Prunning || s == _Psyscall { // 如果 G 運行時時間太長則進行搶占 t := int64(_p_.schedtick) if int64(pd.schedtick) != t { pd.schedtick = uint32(t) pd.schedwhen = now } else if pd.schedwhen+forcePreemptNS <= now { ... sysretake = true } } // 對阻塞在系統調用上的 P 進行搶占 if s == _Psyscall { // 如果已經超過了一個系統監控的 tick(20us),則從系統調用中搶占 P t := int64(_p_.syscalltick) if !sysretake && int64(pd.syscalltick) != t { pd.syscalltick = uint32(t) pd.syscallwhen = now continue } // 一方面,在沒有其他 work 的情況下,我們不希望搶奪 P // 另一方面,因為它可能阻止 sysmon 線程從深度睡眠中喚醒,所以最終我們仍希望搶奪 P if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now { continue } // 解除 allpLock,從而可以獲取 sched.lock unlock(&allpLock) // 在 CAS 之前需要減少空閑 M 的數量(假裝某個還在運行) // 否則發生搶奪的 M 可能退出 syscall 然后再增加 nmidle ,進而發生死鎖 // 這個過程發生在 stoplockedm 中 incidlelocked(-1) if atomic.Cas(&_p_.status, s, _Pidle) { // 將 P 設為 idle,從而交與其他 M 使用 ... n++ _p_.syscalltick++ handoffp(_p_) } incidlelocked(1) lock(&allpLock) } } unlock(&allpLock) return uint32(n) }
在搶占 P 的過程中,有兩個非常小心的處理方式:
鴻蒙官方戰略合作共建——HarmonyOS技術社區
如果此時隊列為空,那么完全沒有必要進行搶占,這時候似乎可以繼續遍歷其他的 P,但必須在調度器中自旋的 M 和 空閑的 P 同時存在時、且系統調用阻塞時間非常長的情況下才能這么做。否則,這個 retake 過程可能返回 0,進而系統監控可能看起來像是什么事情也沒做的情況下調整自己的步調進入深度睡眠。
在將 P 設置為空閑狀態前,必須先將 M 的數量減少,否則當 M 退出系統調用時,會在 exitsyscall0 中調用 stoplockedm 從而增加空閑 M 的數量,進而發生死鎖。
M 搶占
在上面我們沒有展現一個細節,那就是在檢查 P 的狀態時,P 如果是運行狀態會調用preemptone,來通過系統信號來完成搶占,之所以沒有在之前提及的原因在于該調用在 M 不與 P 綁定的情況下是不起任何作用直接返回的。這種異步搶占的本質是:搶占 M。我們不妨繼續從系統監控產生的搶占談起:
func retake(now int64) uint32 { ... for i := 0; i < len(allp); i++ { _p_ := allp[i] ... if s == _Prunning || s == _Psyscall { ... } else if pd.schedwhen+forcePreemptNS <= now { // 對于 syscall 的情況,因為 M 沒有與 P 綁定, // preemptone() 不工作 preemptone(_p_) sysretake = true } } ... } ... } func preemptone(_p_ *p) bool { // 檢查 M 與 P 是否綁定 mp := _p_.m.ptr() if mp == nil || mp == getg().m { return false } gp := mp.curg if gp == nil || gp == mp.g0 { return false } // 將 G 標記為搶占 gp.preempt = true // 一個 Goroutine 中的每個調用都會通過比較當前棧指針和 gp.stackgard0 // 來檢查棧是否溢出。 // 設置 gp.stackgard0 為 StackPreempt 來將搶占轉換為正常的棧溢出檢查。 gp.stackguard0 = stackPreempt // 請求該 P 的異步搶占 if preemptMSupported && debug.asyncpreemptoff == 0 { _p_.preempt = true preemptM(mp) } return true }
搶占信號的選取
preemptM 完成了信號的發送,其實現也非常直接,直接向需要進行搶占的 M 發送 SIGURG 信號即可。但是真正的重要的問題是,為什么是 SIGURG 信號而不是其他的信號?如何才能保證該信號不與用戶態產生的信號產生沖突?這里面有幾個原因:
鴻蒙官方戰略合作共建——HarmonyOS技術社區
默認情況下,SIGURG 已經用于調試器傳遞信號。
SIGURG 可以不加選擇地虛假發生的信號。例如,我們不能選擇 SIGALRM,因為信號處理程序無法分辨它是否是由實際過程引起的(可以說這意味著信號已損壞)。而常見的用戶自定義信號 SIGUSR1 和 SIGUSR2 也不夠好,因為用戶態代碼可能會將其進行使用。
需要處理沒有實時信號的平臺(例如 macOS)。
考慮以上的觀點,SIGURG 其實是一個很好的、滿足所有這些條件、且極不可能因被用戶態代碼進行使用的一種信號。
const sigPreempt = _SIGURG // preemptM 向 mp 發送搶占請求。該請求可以異步處理,也可以與對 M 的其他請求合并。 // 接收到該請求后,如果正在運行的 G 或 P 被標記為搶占,并且 Goroutine 處于異步安全點, // 它將搶占 Goroutine。在處理搶占請求后,它始終以原子方式遞增 mp.preemptGen。 func preemptM(mp *m) { ... signalM(mp, sigPreempt) } func signalM(mp *m, sig int) { tgkill(getpid(), int(mp.procid), sig) }
搶占調用的注入
我們在信號處理一節[5]中已經知道,每個運行的 M 都會設置一個系統信號的處理的回調,當出現系統信號時,操作系統將負責將運行代碼進行中斷,并安全的保護其執行現場,進而 Go 運行時能將針對信號的類型進行處理,當信號處理函數執行結束后,程序會再次進入內核空間,進而恢復到被中斷的位置。
但是這里面有一個很巧妙的用法,因為 sighandler 能夠獲得操作系統所提供的執行上下文參數(例如寄存器 rip, rep 等),如果在 sighandler 中修改了這個上下文參數,OS 會根據就該的寄存器進行恢復,這也就為搶占提供了機會。
//go:nowritebarrierrec func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) { ... c := &sigctxt{info, ctxt} ... if sig == sigPreempt { // 可能是一個搶占信號 doSigPreempt(gp, c) // 即便這是一個搶占信號,它也可能與其他信號進行混合,因此我們 // 繼續進行處理。 } ... } // doSigPreempt 處理了 gp 上的搶占信號 func doSigPreempt(gp *g, ctxt *sigctxt) { // 檢查 G 是否需要被搶占、搶占是否安全 if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) { // 插入搶占調用 ctxt.pushCall(funcPC(asyncPreempt)) } // 記錄搶占 atomic.Xadd(&gp.m.preemptGen, 1)
在 ctxt.pushCall 之前,ctxt.rip() 和 ctxt.rep() 都保存了被中斷的 Goroutine 所在的位置,但是 pushCall 直接修改了這些寄存器,進而當從 sighandler 返回用戶態 Goroutine 時,能夠從注入的 asyncPreempt 開始執行:
func (c *sigctxt) pushCall(targetPC uintptr) { pc := uintptr(c.rip()) sp := uintptr(c.rsp()) sp -= sys.PtrSize *(*uintptr)(unsafe.Pointer(sp)) = pc c.set_rsp(uint64(sp)) c.set_rip(uint64(targetPC)) }
完成 sighandler 之,我們成功恢復到 asyncPreempt 調用:
// asyncPreempt 保存了所有用戶寄存器,并調用 asyncPreempt2 // // 當棧掃描遭遇 asyncPreempt 棧幀時,將會保守的掃描調用方棧幀 func asyncPreempt()
該函數的主要目的是保存用戶態寄存器,并且在調用完畢前恢復所有的寄存器上下文就好像什么事情都沒有發生過一樣:
TEXT ·asyncPreempt(SB),NOSPLIT|NOFRAME,$0-0 ... MOVQ AX, 0(SP) ... MOVUPS X15, 352(SP) CALL ·asyncPreempt2(SB) MOVUPS 352(SP), X15 ... MOVQ 0(SP), AX ... RET
當調用 asyncPreempt2 時,會根據 preemptPark 或者 gopreempt_m 重新切換回調度循環,從而打斷密集循環的繼續執行。
//go:nosplit func asyncPreempt2() { gp := getg() gp.asyncSafePoint = true if gp.preemptStop { mcall(preemptPark) } else { mcall(gopreempt_m) } // 異步搶占過程結束 gp.asyncSafePoint = false }
至此,異步搶占過程結束。我們總結一下搶占調用的整體邏輯:
M1 發送中斷信號(signalM(mp, sigPreempt))
M2 收到信號,操作系統中斷其執行代碼,并切換到信號處理函數(sighandler(signum, info, ctxt, gp))
M2 修改執行的上下文,并恢復到修改后的位置(asyncPreempt)
重新進入調度循環進而調度其他 Goroutine(preemptPark 和 gopreempt_m)
上述的異步搶占流程我們是通過系統監控來說明的,正如前面所提及的,異步搶占的本質是在為垃圾回收器服務,由于我們還沒有討論過 Go 語言垃圾回收的具體細節,這里便不做過多展開,讀者只需理解,在垃圾回收周期開始時,垃圾回收器將通過上述異步搶占的邏輯,停止所有用戶 Goroutine,進而轉去執行垃圾回收。
到此,關于“分析Go協作與搶占”的學習就結束了,希望能夠解決大家的疑惑。理論與實踐的搭配能更好的幫助大家學習,快去試試吧!若想繼續學習更多相關知識,請繼續關注億速云網站,小編會繼續努力為大家帶來更多實用的文章!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。