聊一聊Go 協(xié)作與搶占
- 協(xié)作式調(diào)度
- 主動用戶讓權(quán):Gosched
- 主動調(diào)度棄權(quán):棧擴張與搶占標記
- 搶占式調(diào)度
- P 搶占
- M 搶占
- 小結(jié)
- 進一步閱讀的參考文獻
我們在分析調(diào)度循環(huán)[1]的時候總結(jié)過一個問題:如果某個 G 執(zhí)行時間過長,其他的 G 如何才能被正常地調(diào)度?這便涉及到有關(guān)調(diào)度的兩個理念:協(xié)作式調(diào)度與搶占式調(diào)度。
協(xié)作式和搶占式這兩個理念解釋起來很簡單:協(xié)作式調(diào)度依靠被調(diào)度方主動棄權(quán);搶占式調(diào)度則依靠調(diào)度器強制將被調(diào)度方被動中斷。這兩個概念其實描述了調(diào)度的兩種截然不同的策略,這兩種決策模式,在調(diào)度理論中其實已經(jīng)研究得很透徹了。
Go 的運行時并不具備操作系統(tǒng)內(nèi)核級的硬件中斷能力,基于工作竊取的調(diào)度器實現(xiàn),本質(zhì)上屬于先來先服務(wù)的協(xié)作式調(diào)度,為了解決響應(yīng)時間可能較高的問題,目前運行時實現(xiàn)了兩種不同的調(diào)度策略、每種策略各兩個形式。保證在大部分情況下,不同的 G 能夠獲得均勻的時間片:
- 同步協(xié)作式調(diào)度
- 主動用戶讓權(quán):通過 runtime.Gosched 調(diào)用主動讓出執(zhí)行機會;
- 主動調(diào)度棄權(quán):當發(fā)生執(zhí)行棧分段時,檢查自身的搶占標記,決定是否繼續(xù)執(zhí)行;
- 異步搶占式調(diào)度
- 被動監(jiān)控搶占:當 G 阻塞在 M 上時(系統(tǒng)調(diào)用、channel 等),系統(tǒng)監(jiān)控會將 P 從 M 上搶奪并分配給其他的 M 來執(zhí)行其他的 G,而位于被搶奪 P 的本地調(diào)度隊列中的 G 則可能會被偷取到其他 M 執(zhí)行。
- 被動 GC 搶占:當需要進行垃圾回收時,為了保證不具備主動搶占處理的函數(shù)執(zhí)行時間過長,導(dǎo)致垃圾回收遲遲不能執(zhí)行而導(dǎo)致的高延遲,而強制停止 G 并轉(zhuǎn)為執(zhí)行垃圾回收。
協(xié)作式調(diào)度
主動用戶讓權(quán):Gosched
Gosched 是一種主動放棄執(zhí)行的手段,用戶態(tài)代碼通過調(diào)用此接口來出讓執(zhí)行機會,使其他“人”也能在密集的執(zhí)行過程中獲得被調(diào)度的機會。
Gosched 的實現(xiàn)非常簡單:
- // Gosched 會讓出當前的 P,并允許其他 Goroutine 運行。
- // 它不會推遲當前的 Goroutine,因此執(zhí)行會被自動恢復(fù)
- func Gosched() {
- checkTimeouts()
- mcall(gosched_m)
- }
- // Gosched 在 g0 上繼續(xù)執(zhí)行
- func gosched_m(gp *g) {
- ...
- goschedImpl(gp)
- }
它首先會通過 note 機制通知那些等待被 ready 的 Goroutine:
- // checkTimeouts 恢復(fù)那些在等待一個 note 且已經(jīng)觸發(fā)其 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")
- }
- // 狀態(tài)為 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 中已經(jīng)清除它的情況下恢復(fù)搶占請求
- _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 調(diào)用 gosched_m 在 g0 上繼續(xù)執(zhí)行并讓出 P,實質(zhì)上是讓 G 放棄當前在 M 上的執(zhí)行權(quán)利,M 轉(zhuǎn)去執(zhí)行其他的 G,并在上下文切換時候,將自身放入全局隊列等待后續(xù)調(diào)度:
- func goschedImpl(gp *g) {
- // 放棄當前 g 的運行狀態(tài)
- status := readgstatus(gp)
- ...
- casgstatus(gp, _Grunning, _Grunnable)
- // 使當前 m 放棄 g
- dropg()
- // 并將 g 放回全局隊列中
- lock(&sched.lock)
- globrunqput(gp)
- unlock(&sched.lock)
- // 重新進入調(diào)度循環(huán)
- schedule()
- }
當然,盡管具有主動棄權(quán)的能力,但它對 Go 語言的用戶要求比較高,因為用戶在編寫并發(fā)邏輯的時候需要自行甄別是否需要讓出時間片,這并非用戶友好的,而且很多 Go 的新用戶并不會了解到這個問題的存在,我們在隨后的搶占式調(diào)度中再進一步展開討論。
主動調(diào)度棄權(quán):棧擴張與搶占標記
另一種主動放棄的方式是通過搶占標記的方式實現(xiàn)的?;鞠敕ㄊ窃诿總€函數(shù)調(diào)用的序言(函數(shù)調(diào)用的最前方)插入搶占檢測指令,當檢測到當前 Goroutine 被標記為應(yīng)該被搶占時,則主動中斷執(zhí)行,讓出執(zhí)行權(quán)利。表面上看起來想法很簡單,但實施起來就比較復(fù)雜了。
在 6.6 執(zhí)行棧管理[2] 一節(jié)中我們已經(jīng)了解到,函數(shù)調(diào)用的序言部分會檢查 SP 寄存器與 stackguard0 之間的大小,如果 SP 小于 stackguard0 則會觸發(fā) morestack_noctxt,觸發(fā)棧分段操作。換言之,如果搶占標記將 stackgard0 設(shè)為比所有可能的 SP 都要大(即 stackPreempt),則會觸發(fā) morestack,進而調(diào)用 newstack:
- // Goroutine 搶占請求
- // 存儲到 g.stackguard0 來導(dǎo)致棧分段檢查失敗
- // 必須比任何實際的 SP 都要大
- // 十六進制為:0xfffffade
- const stackPreempt = (1<<(8*sys.PtrSize) - 1) & -1314
從搶占調(diào)度的角度來看,這種發(fā)生在函數(shù)序言部分的搶占的一個重要目的就是能夠簡單且安全的記錄執(zhí)行現(xiàn)場(隨后的搶占式調(diào)度我們會看到記錄執(zhí)行現(xiàn)場給采用信號方式中斷線程執(zhí)行的調(diào)度帶來多大的困難)。事實也是如此,在 morestack 調(diào)用中:
- 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 寄存器,而后才開始調(diào)用 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
- // 如果是發(fā)起的搶占請求而非真正的棧分段
- preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt
- // 保守的對用戶態(tài)代碼進行搶占,而非搶占運行時代碼
- // 如果正持有鎖、分配內(nèi)存或搶占被禁用,則不發(fā)生搶占
- if preempt {
- if !canPreemptM(thisg.m) {
- // 不發(fā)生搶占,繼續(xù)調(diào)度
- gp.stackguard0 = gp.stack.lo + _StackGuard
- gogo(&gp.sched) // 重新進入調(diào)度循環(huán)
- }
- }
- ...
- // 如果需要對棧進行調(diào)整
- if preempt {
- ...
- if gp.preemptShrink {
- // 我們正在一個同步安全點,因此等待棧收縮
- gp.preemptShrink = false
- shrinkstack(gp)
- }
- if gp.preemptStop {
- preemptPark(gp) // 永不返回
- }
- ...
- // 表現(xiàn)得像是調(diào)用了 runtime.Gosched,主動讓權(quán)
- gopreempt_m(gp) // 重新進入調(diào)度循環(huán)
- }
- ...
- }
- // 與 gosched_m 一致
- func gopreempt_m(gp *g) {
- ...
- goschedImpl(gp)
- }
其中的 canPreemptM 驗證了可以被搶占的條件:
- 運行時沒有禁止搶占(m.locks == 0)
- 運行時沒有在執(zhí)行內(nèi)存分配(m.mallocing == 0)
- 運行時沒有關(guān)閉搶占機制(m.preemptoff == "")
- M 與 P 綁定且沒有進入系統(tǒng)調(diào)用(p.status == _Prunning)
- // canPreemptM 報告 mp 是否處于可搶占的安全狀態(tài)。
- //go:nosplit
- func canPreemptM(mp *m) bool {
- return mp.locks == 0 && mp.mallocing == 0 && mp.preemptoff == "" && mp.p.ptr().status == _Prunning
- }
從可被搶占的條件來看,能夠?qū)σ粋€ G 進行搶占其實是呈保守狀態(tài)的。這一保守體現(xiàn)在搶占對很多運行時所需的條件進行了判斷,這也理所當然是因為運行時優(yōu)先級更高,不應(yīng)該輕易發(fā)生搶占,但與此同時由于又需要對用戶態(tài)代碼進行搶占,于是先作出一次不需要搶占的判斷(快速路徑),確定不能搶占時返回并繼續(xù)調(diào)度,如果真的需要進行搶占,則轉(zhuǎn)入調(diào)用 gopreempt_m,放棄當前 G 的執(zhí)行權(quán),將其加入全局隊列,重新進入調(diào)度循環(huán)。
什么時候會給 stackguard0 設(shè)置搶占標記 stackPreempt 呢?一共有以下幾種情況:
- 進入系統(tǒng)調(diào)用時(runtime.reentersyscall,注意這種情況是為了保證不會發(fā)生棧分裂,真正的搶占是異步地通過系統(tǒng)監(jiān)控進行的)
- 任何運行時不再持有鎖的時候(m.locks == 0)
- 當垃圾回收器需要停止所有用戶 Goroutine 時
搶占式調(diào)度
從上面提到的兩種協(xié)作式調(diào)度邏輯我們可以看出,這種需要用戶代碼來主動配合的調(diào)度方式存在一些致命的缺陷:一個沒有主動放棄執(zhí)行權(quán)、且不參與任何函數(shù)調(diào)用的函數(shù),直到執(zhí)行完畢之前,是不會被搶占的。
那么這種不會被搶占的函數(shù)會導(dǎo)致什么嚴重的問題呢?回答是,由于運行時無法停止該用戶代碼,則當需要進行垃圾回收時,無法及時進行;對于一些實時性要求較高的用戶態(tài) Goroutine 而言,也久久得不到調(diào)度。我們這里不去深入討論垃圾回收的具體細節(jié),讀者將在垃圾回收器[3]一章中詳細看到這類問題導(dǎo)致的后果。單從調(diào)度的角度而言,我們直接來看一個非常簡單的例子:
- // 此程序在 Go 1.14 之前的版本不會輸出 OK
- package main
- import (
- "runtime"
- "time"
- )
- func main() {
- runtime.GOMAXPROCS(1)
- go func() {
- for {
- }
- }()
- time.Sleep(time.Millisecond)
- println("OK")
- }
這段代碼中處于死循環(huán)的 Goroutine 永遠無法被搶占,其中創(chuàng)建的 Goroutine 會執(zhí)行一個不產(chǎn)生任何調(diào)用、不主動放棄執(zhí)行權(quán)的死循環(huán)。由于主 Goroutine 優(yōu)先調(diào)用了休眠,此時唯一的 P 會轉(zhuǎn)去執(zhí)行 for 循環(huán)所創(chuàng)建的 Goroutine。進而主 Goroutine 永遠不會再被調(diào)度,進而程序徹底阻塞在了這個 Goroutine 上,永遠無法退出。這樣的例子非常多,但追根溯源,均為此問題導(dǎo)致。
Go 團隊其實很早(1.0 以前)就已經(jīng)意識到了這個問題,但在 Go 1.2 時增加了上文提到的在函數(shù)序言部分增加搶占標記后,此問題便被擱置,直到越來越多的用戶提交并報告此問題。在 Go 1.5 前后,Austin Clements 希望僅解決這種由密集循環(huán)導(dǎo)致的無法搶占的問題 [Clements, 2015],于是嘗試通過協(xié)作式 loop 循環(huán)搶占,通過編譯器輔助的方式,插入搶占檢查指令,與流程圖回邊(指節(jié)點被訪問過但其子節(jié)點尚未訪問完畢)安全點(在一個線程執(zhí)行中,垃圾回收器能夠識別所有對象引用狀態(tài)的一個狀態(tài))的方式進行解決。
盡管此舉能為搶占帶來顯著的提升,但是在一個循環(huán)中引入分支顯然會降低性能。盡管隨后 David Chase 對這個方法進行了改進,僅在插入了一條 TESTB 指令 [Chase, 2017],在完全沒有分支以及寄存器壓力的情況下,仍然造成了幾何平均 7.8% 的性能損失。這種結(jié)果其實是情理之中的,很多需要進行密集循環(huán)的計算時間都是在運行時才能確定的,直接由編譯器檢測這類密集循環(huán)而插入額外的指令可想而知是欠妥的做法。
終于在 Go 1.10 后 [Clements, 2019],Austin 進一步提出的解決方案,希望使用每個指令與執(zhí)行棧和寄存器的映射關(guān)系,通過記錄足夠多的信息,并通過異步線程來發(fā)送搶占信號的方式來支持異步搶占式調(diào)度。
我們知道現(xiàn)代操作系統(tǒng)的調(diào)度器多為搶占式調(diào)度,其實現(xiàn)方式通過硬件中斷來支持線程的切換,進而能安全的保存運行上下文。在 Go 運行時實現(xiàn)搶占式調(diào)度同樣也可以使用類似的方式,通過向線程發(fā)送系統(tǒng)信號的方式來中斷 M 的執(zhí)行,進而達到搶占的目的。但與操作系統(tǒng)的不同之處在于,由于運行時諸多機制的存在(例如垃圾回收器),還必須能夠在 Goroutine 被停止時,保存充足的上下文信息(見 8.9 安全點分析[4])。這就給中斷信號帶來了麻煩,如果中斷信號恰好發(fā)生在一些關(guān)鍵階段(例如寫屏障期間),則無法保證程序的正確性。這也就要求我們需要嚴格考慮觸發(fā)異步搶占的時機。
異步搶占式調(diào)度的一種方式就與運行時系統(tǒng)監(jiān)控有關(guān),監(jiān)控循環(huán)會將發(fā)生阻塞的 Goroutine 搶占,解綁 P 與 M,從而讓其他的線程能夠獲得 P 繼續(xù)執(zhí)行其他的 Goroutine。這得益于 sysmon中調(diào)用的 retake 方法。這個方法處理了兩種搶占情況,一是搶占阻塞在系統(tǒng)調(diào)用上的 P,二是搶占運行時間過長的 G。其中搶占運行時間過長的 G 這一方式還會出現(xiàn)在垃圾回收需要進入 STW 時。
P 搶占
我們先來看搶占阻塞在系統(tǒng)調(diào)用上的 G 這種情況。這種搶占的實現(xiàn)方法非常的自然,因為 Goroutine 已經(jīng)阻塞在了系統(tǒng)調(diào)用上,我們可以非常安全的將 M 與 P 進行解綁,即便是 Goroutine 從阻塞中恢復(fù),也會檢查自身所在的 M 是否仍然持有 P,如果沒有 P 則重新考慮與可用的 P 進行綁定。這種異步搶占的本質(zhì)是:搶占 P。
- unc retake(now int64) uint32 {
- n := 0
- // 防止 allp 數(shù)組發(fā)生變化,除非我們已經(jīng) 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
- }
- }
- // 對阻塞在系統(tǒng)調(diào)用上的 P 進行搶占
- if s == _Psyscall {
- // 如果已經(jīng)超過了一個系統(tǒng)監(jiān)控的 tick(20us),則從系統(tǒng)調(diào)用中搶占 P
- t := int64(_p_.syscalltick)
- if !sysretake && int64(pd.syscalltick) != t {
- pd.syscalltick = uint32(t)
- pd.syscallwhen = now
- continue
- }
- // 一方面,在沒有其他 work 的情況下,我們不希望搶奪 P
- // 另一方面,因為它可能阻止 sysmon 線程從深度睡眠中喚醒,所以最終我們?nèi)韵M麚寠Z 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 的數(shù)量(假裝某個還在運行)
- // 否則發(fā)生搶奪的 M 可能退出 syscall 然后再增加 nmidle ,進而發(fā)生死鎖
- // 這個過程發(fā)生在 stoplockedm 中
- incidlelocked(-1)
- if atomic.Cas(&_p_.status, s, _Pidle) { // 將 P 設(shè)為 idle,從而交與其他 M 使用
- ...
- n++
- _p_.syscalltick++
- handoffp(_p_)
- }
- incidlelocked(1)
- lock(&allpLock)
- }
- }
- unlock(&allpLock)
- return uint32(n)
- }
在搶占 P 的過程中,有兩個非常小心的處理方式:
- 如果此時隊列為空,那么完全沒有必要進行搶占,這時候似乎可以繼續(xù)遍歷其他的 P,但必須在調(diào)度器中自旋的 M 和 空閑的 P 同時存在時、且系統(tǒng)調(diào)用阻塞時間非常長的情況下才能這么做。否則,這個 retake 過程可能返回 0,進而系統(tǒng)監(jiān)控可能看起來像是什么事情也沒做的情況下調(diào)整自己的步調(diào)進入深度睡眠。
- 在將 P 設(shè)置為空閑狀態(tài)前,必須先將 M 的數(shù)量減少,否則當 M 退出系統(tǒng)調(diào)用時,會在 exitsyscall0 中調(diào)用 stoplockedm 從而增加空閑 M 的數(shù)量,進而發(fā)生死鎖。
M 搶占
在上面我們沒有展現(xiàn)一個細節(jié),那就是在檢查 P 的狀態(tài)時,P 如果是運行狀態(tài)會調(diào)用preemptone,來通過系統(tǒng)信號來完成搶占,之所以沒有在之前提及的原因在于該調(diào)用在 M 不與 P 綁定的情況下是不起任何作用直接返回的。這種異步搶占的本質(zhì)是:搶占 M。我們不妨繼續(xù)從系統(tǒng)監(jiān)控產(chǎn)生的搶占談起:
- 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 中的每個調(diào)用都會通過比較當前棧指針和 gp.stackgard0
- // 來檢查棧是否溢出。
- // 設(shè)置 gp.stackgard0 為 StackPreempt 來將搶占轉(zhuǎn)換為正常的棧溢出檢查。
- gp.stackguard0 = stackPreempt
- // 請求該 P 的異步搶占
- if preemptMSupported && debug.asyncpreemptoff == 0 {
- _p_.preempt = true
- preemptM(mp)
- }
- return true
- }
搶占信號的選取
preemptM 完成了信號的發(fā)送,其實現(xiàn)也非常直接,直接向需要進行搶占的 M 發(fā)送 SIGURG 信號即可。但是真正的重要的問題是,為什么是 SIGURG 信號而不是其他的信號?如何才能保證該信號不與用戶態(tài)產(chǎn)生的信號產(chǎn)生沖突?這里面有幾個原因:
- 默認情況下,SIGURG 已經(jīng)用于調(diào)試器傳遞信號。
- SIGURG 可以不加選擇地虛假發(fā)生的信號。例如,我們不能選擇 SIGALRM,因為信號處理程序無法分辨它是否是由實際過程引起的(可以說這意味著信號已損壞)。而常見的用戶自定義信號 SIGUSR1 和 SIGUSR2 也不夠好,因為用戶態(tài)代碼可能會將其進行使用。
- 需要處理沒有實時信號的平臺(例如 macOS)。
考慮以上的觀點,SIGURG 其實是一個很好的、滿足所有這些條件、且極不可能因被用戶態(tài)代碼進行使用的一種信號。
- const sigPreempt = _SIGURG
- // preemptM 向 mp 發(fā)送搶占請求。該請求可以異步處理,也可以與對 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)
- }
搶占調(diào)用的注入
我們在信號處理一節(jié)[5]中已經(jīng)知道,每個運行的 M 都會設(shè)置一個系統(tǒng)信號的處理的回調(diào),當出現(xiàn)系統(tǒng)信號時,操作系統(tǒng)將負責(zé)將運行代碼進行中斷,并安全的保護其執(zhí)行現(xiàn)場,進而 Go 運行時能將針對信號的類型進行處理,當信號處理函數(shù)執(zhí)行結(jié)束后,程序會再次進入內(nèi)核空間,進而恢復(fù)到被中斷的位置。
但是這里面有一個很巧妙的用法,因為 sighandler 能夠獲得操作系統(tǒng)所提供的執(zhí)行上下文參數(shù)(例如寄存器 rip, rep 等),如果在 sighandler 中修改了這個上下文參數(shù),OS 會根據(jù)就該的寄存器進行恢復(fù),這也就為搶占提供了機會。
- //go:nowritebarrierrec
- func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
- ...
- c := &sigctxt{info, ctxt}
- ...
- if sig == sigPreempt {
- // 可能是一個搶占信號
- doSigPreempt(gp, c)
- // 即便這是一個搶占信號,它也可能與其他信號進行混合,因此我們
- // 繼續(xù)進行處理。
- }
- ...
- }
- // doSigPreempt 處理了 gp 上的搶占信號
- func doSigPreempt(gp *g, ctxt *sigctxt) {
- // 檢查 G 是否需要被搶占、搶占是否安全
- if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) {
- // 插入搶占調(diào)用
- ctxt.pushCall(funcPC(asyncPreempt))
- }
- // 記錄搶占
- atomic.Xadd(&gp.m.preemptGen, 1)
在 ctxt.pushCall 之前,ctxt.rip() 和 ctxt.rep() 都保存了被中斷的 Goroutine 所在的位置,但是 pushCall 直接修改了這些寄存器,進而當從 sighandler 返回用戶態(tài) Goroutine 時,能夠從注入的 asyncPreempt 開始執(zhí)行:
- 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 之,我們成功恢復(fù)到 asyncPreempt 調(diào)用:
- // asyncPreempt 保存了所有用戶寄存器,并調(diào)用 asyncPreempt2
- //
- // 當棧掃描遭遇 asyncPreempt 棧幀時,將會保守的掃描調(diào)用方棧幀
- func asyncPreempt()
該函數(shù)的主要目的是保存用戶態(tài)寄存器,并且在調(diào)用完畢前恢復(fù)所有的寄存器上下文就好像什么事情都沒有發(fā)生過一樣:
- 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
當調(diào)用 asyncPreempt2 時,會根據(jù) preemptPark 或者 gopreempt_m 重新切換回調(diào)度循環(huán),從而打斷密集循環(huán)的繼續(xù)執(zhí)行。
- //go:nosplit
- func asyncPreempt2() {
- gp := getg()
- gp.asyncSafePoint = true
- if gp.preemptStop {
- mcall(preemptPark)
- } else {
- mcall(gopreempt_m)
- }
- // 異步搶占過程結(jié)束
- gp.asyncSafePoint = false
- }
至此,異步搶占過程結(jié)束。我們總結(jié)一下?lián)屨颊{(diào)用的整體邏輯:
- M1 發(fā)送中斷信號(signalM(mp, sigPreempt))
- M2 收到信號,操作系統(tǒng)中斷其執(zhí)行代碼,并切換到信號處理函數(shù)(sighandler(signum, info, ctxt, gp))
- M2 修改執(zhí)行的上下文,并恢復(fù)到修改后的位置(asyncPreempt)
- 重新進入調(diào)度循環(huán)進而調(diào)度其他 Goroutine(preemptPark 和 gopreempt_m)
上述的異步搶占流程我們是通過系統(tǒng)監(jiān)控來說明的,正如前面所提及的,異步搶占的本質(zhì)是在為垃圾回收器服務(wù),由于我們還沒有討論過 Go 語言垃圾回收的具體細節(jié),這里便不做過多展開,讀者只需理解,在垃圾回收周期開始時,垃圾回收器將通過上述異步搶占的邏輯,停止所有用戶 Goroutine,進而轉(zhuǎn)去執(zhí)行垃圾回收。
小結(jié)
總的來說,應(yīng)用層的調(diào)度策略不易實現(xiàn),因此實現(xiàn)上也并不是特別緊急。我們回顧 Go 語言調(diào)度策略的調(diào)整過程不難發(fā)現(xiàn),實現(xiàn)它們的動力是從實際需求出發(fā)的。Go 語言從設(shè)計之初并沒有刻意地去考慮對 Goroutine 的搶占機制。從早期無法對 Goroutine 進行搶占的原始時代,到現(xiàn)在的協(xié)作與搶占同時配合的調(diào)度策略,其問題的核心是垃圾回收的需要。
運行時需要執(zhí)行垃圾回收時,協(xié)作式調(diào)度能夠保證具備函數(shù)調(diào)用的用戶 Goroutine 正常停止;搶占式調(diào)度則能避免由于死循環(huán)導(dǎo)致的任意時間的垃圾回收延遲。至此,Go 語言的用戶可以放心地寫出各種形式的代碼邏輯,運行時垃圾回收也能夠在適當?shù)臅r候及時中斷用戶代碼,不至于導(dǎo)致整個系統(tǒng)進入不可預(yù)測的停頓。
進一步閱讀的參考文獻[Clements, 2019] Austin Clements. Proposal: Non-cooperative goroutine preemption. January 18, 2019. https://github.com/golang/proposal/blob/master/design/24543-non-cooperative-preemption.md
[Clements, 2015] Austin Clements. runtime: tight loops should be preemptible](https://golang.org/issue/10958
[Chase, 2017] David Chase. cmd/compile: loop preemption with "fault branch" on amd64. May 09, 2019. https://golang.org/cl/43050
參考資料
[1]分析調(diào)度循環(huán): https://changkun.de/golang/zh-cn/part2runtime/ch06sched/exec[2]6.6 執(zhí)行棧管理: https://changkun.de/golang/zh-cn/part2runtime/ch06sched/stack/[3]垃圾回收器: https://github.com/qcrao/Go-Questions/blob/master/GC/GC.md[4]8.9 安全點分析: https://changkun.de/golang/zh-cn/part2runtime/ch08gc/safe[5]信號處理一節(jié): https://changkun.de/golang/zh-cn/part2runtime/ch06sched/signal/
本文作者歐長坤,德國慕尼黑大學(xué)在讀博士,Go/etcd/Tensorflow contributor,開源書籍《Go 語言原本》作者,《Go 夜讀》SIG 成員/講師,對 Go 有很深的研究。Github:@changkun,https://changkun.de。