自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

聊一聊Go 協(xié)作與搶占

開發(fā) 前端
Go 的運行時并不具備操作系統(tǒng)內(nèi)核級的硬件中斷能力,基于工作竊取的調(diào)度器實現(xiàn),本質(zhì)上屬于先來先服務(wù)的協(xié)作式調(diào)度,為了解決響應(yīng)時間可能較高的問題,目前運行時實現(xiàn)了兩種不同的調(diào)度策略、每種策略各兩個形式。

[[323539]]

本文轉(zhuǎn)載自微信公眾號「 碼農(nóng)桃花源」,轉(zhuǎn)載本文請聯(lián)系 碼農(nóng)桃花源公眾號。

  • 協(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)度
  1. 主動用戶讓權(quán):通過 runtime.Gosched 調(diào)用主動讓出執(zhí)行機會;
  2. 主動調(diào)度棄權(quán):當發(fā)生執(zhí)行棧分段時,檢查自身的搶占標記,決定是否繼續(xù)執(zhí)行;
  • 異步搶占式調(diào)度
  1. 被動監(jiān)控搶占:當 G 阻塞在 M 上時(系統(tǒng)調(diào)用、channel 等),系統(tǒng)監(jiān)控會將 P 從 M 上搶奪并分配給其他的 M 來執(zhí)行其他的 G,而位于被搶奪 P 的本地調(diào)度隊列中的 G 則可能會被偷取到其他 M 執(zhí)行。
  2. 被動 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)非常簡單:

  1. // Gosched 會讓出當前的 P,并允許其他 Goroutine 運行。 
  2. // 它不會推遲當前的 Goroutine,因此執(zhí)行會被自動恢復(fù) 
  3. func Gosched() { 
  4.   checkTimeouts() 
  5.   mcall(gosched_m) 
  6.  
  7. // Gosched 在 g0 上繼續(xù)執(zhí)行 
  8. func gosched_m(gp *g) { 
  9.   ... 
  10.   goschedImpl(gp) 

它首先會通過 note 機制通知那些等待被 ready 的 Goroutine:

  1. // checkTimeouts 恢復(fù)那些在等待一個 note 且已經(jīng)觸發(fā)其 deadline 時的 Goroutine。 
  2. func checkTimeouts() { 
  3.   now := nanotime() 
  4.   for n, nt := range notesWithTimeout { 
  5.     if n.key == note_cleared && now > nt.deadline { 
  6.       n.key = note_timeout 
  7.       goready(nt.gp, 1) 
  8.     } 
  9.   } 
  10.  
  11. func goready(gp *g, traceskip int) { 
  12.   systemstack(func() { 
  13.     ready(gp, traceskip, true
  14.   }) 
  15.  
  16. // 將 gp 標記為 ready 來運行 
  17. func ready(gp *g, traceskip intnext bool) { 
  18.   if trace.enabled { 
  19.     traceGoUnpark(gp, traceskip) 
  20.   } 
  21.  
  22.   status := readgstatus(gp) 
  23.  
  24.   // 標記為 runnable. 
  25.   _g_ := getg() 
  26.   _g_.m.locks++ // 禁止搶占,因為它可以在局部變量中保存 p 
  27.   if status&^_Gscan != _Gwaiting { 
  28.     dumpgstatus(gp) 
  29.     throw("bad g->status in ready"
  30.   } 
  31.  
  32.   // 狀態(tài)為 Gwaiting 或 Gscanwaiting, 標記 Grunnable 并將其放入運行隊列 runq 
  33.   casgstatus(gp, _Gwaiting, _Grunnable) 
  34.   runqput(_g_.m.p.ptr(), gp, next
  35.   if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 { 
  36.     wakep() 
  37.   } 
  38.   _g_.m.locks-- 
  39.   if _g_.m.locks == 0 && _g_.preempt { // 在 newstack 中已經(jīng)清除它的情況下恢復(fù)搶占請求 
  40.     _g_.stackguard0 = stackPreempt 
  41.   } 
  42.  
  43. func notetsleepg(n *note, ns int64) bool { 
  44.   gp := getg() 
  45.   ... 
  46.  
  47.   if ns >= 0 { 
  48.     deadline := nanotime() + ns 
  49.     ... 
  50.     notesWithTimeout[n] = noteWithTimeout{gp: gp, deadline: deadline} 
  51.     ... 
  52.     gopark(nil, nil, waitReasonSleep, traceEvNone, 1) 
  53.     ... 
  54.     delete(notesWithTimeout, n) 
  55.     ... 
  56.   } 
  57.  
  58.   ... 

而后通過 mcall 調(diào)用 gosched_m 在 g0 上繼續(xù)執(zhí)行并讓出 P,實質(zhì)上是讓 G 放棄當前在 M 上的執(zhí)行權(quán)利,M 轉(zhuǎn)去執(zhí)行其他的 G,并在上下文切換時候,將自身放入全局隊列等待后續(xù)調(diào)度:

  1. func goschedImpl(gp *g) { 
  2.   // 放棄當前 g 的運行狀態(tài) 
  3.   status := readgstatus(gp) 
  4.   ... 
  5.   casgstatus(gp, _Grunning, _Grunnable) 
  6.   // 使當前 m 放棄 g 
  7.   dropg() 
  8.   // 并將 g 放回全局隊列中 
  9.   lock(&sched.lock) 
  10.   globrunqput(gp) 
  11.   unlock(&sched.lock) 
  12.  
  13.   // 重新進入調(diào)度循環(huán) 
  14.   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:

  1. // Goroutine 搶占請求 
  2. // 存儲到 g.stackguard0 來導(dǎo)致棧分段檢查失敗 
  3. // 必須比任何實際的 SP 都要大 
  4. // 十六進制為:0xfffffade 
  5. 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)用中:

  1. TEXT runtime·morestack(SB),NOSPLIT,$0-0 
  2.     ... 
  3.     MOVQ    0(SP), AX // f's PC 
  4.     MOVQ    AX, (g_sched+gobuf_pc)(SI) 
  5.     MOVQ    SI, (g_sched+gobuf_g)(SI) 
  6.     LEAQ    8(SP), AX // f's SP 
  7.     MOVQ    AX, (g_sched+gobuf_sp)(SI) 
  8.     MOVQ    BP, (g_sched+gobuf_bp)(SI) 
  9.     MOVQ    DX, (g_sched+gobuf_ctxt)(SI) 
  10.     ... 
  11.     CALL    runtime·newstack(SB) 

是有記錄 Goroutine 的 PC 和 SP 寄存器,而后才開始調(diào)用 newstack 的:

  1. //go:nowritebarrierrec 
  2. func newstack() { 
  3.   thisg := getg() 
  4.   ... 
  5.  
  6.   gp := thisg.m.curg 
  7.   ... 
  8.  
  9.   morebuf := thisg.m.morebuf 
  10.   thisg.m.morebuf.pc = 0 
  11.   thisg.m.morebuf.lr = 0 
  12.   thisg.m.morebuf.sp = 0 
  13.   thisg.m.morebuf.g = 0 
  14.  
  15.   // 如果是發(fā)起的搶占請求而非真正的棧分段 
  16.   preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt 
  17.  
  18.   // 保守的對用戶態(tài)代碼進行搶占,而非搶占運行時代碼 
  19.   // 如果正持有鎖、分配內(nèi)存或搶占被禁用,則不發(fā)生搶占 
  20.   if preempt { 
  21.     if !canPreemptM(thisg.m) { 
  22.       // 不發(fā)生搶占,繼續(xù)調(diào)度 
  23.       gp.stackguard0 = gp.stack.lo + _StackGuard 
  24.       gogo(&gp.sched) // 重新進入調(diào)度循環(huán) 
  25.     } 
  26.   } 
  27.   ... 
  28.   // 如果需要對棧進行調(diào)整 
  29.   if preempt { 
  30.     ... 
  31.     if gp.preemptShrink { 
  32.       // 我們正在一個同步安全點,因此等待棧收縮 
  33.       gp.preemptShrink = false 
  34.       shrinkstack(gp) 
  35.     } 
  36.     if gp.preemptStop { 
  37.       preemptPark(gp) // 永不返回 
  38.     } 
  39.     ... 
  40.     // 表現(xiàn)得像是調(diào)用了 runtime.Gosched,主動讓權(quán) 
  41.     gopreempt_m(gp) // 重新進入調(diào)度循環(huán) 
  42.   } 
  43.   ... 
  44. // 與 gosched_m 一致 
  45. func gopreempt_m(gp *g) { 
  46.   ... 
  47.   goschedImpl(gp) 

其中的 canPreemptM 驗證了可以被搶占的條件:

  1. 運行時沒有禁止搶占(m.locks == 0)
  2. 運行時沒有在執(zhí)行內(nèi)存分配(m.mallocing == 0)
  3. 運行時沒有關(guān)閉搶占機制(m.preemptoff == "")
  4. M 與 P 綁定且沒有進入系統(tǒng)調(diào)用(p.status == _Prunning)
  1. // canPreemptM 報告 mp 是否處于可搶占的安全狀態(tài)。 
  2. //go:nosplit 
  3. func canPreemptM(mp *m) bool { 
  4.   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 呢?一共有以下幾種情況:

  1. 進入系統(tǒng)調(diào)用時(runtime.reentersyscall,注意這種情況是為了保證不會發(fā)生棧分裂,真正的搶占是異步地通過系統(tǒng)監(jiān)控進行的)
  2. 任何運行時不再持有鎖的時候(m.locks == 0)
  3. 當垃圾回收器需要停止所有用戶 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)度的角度而言,我們直接來看一個非常簡單的例子:

  1. // 此程序在 Go 1.14 之前的版本不會輸出 OK 
  2. package main 
  3. import ( 
  4.   "runtime" 
  5.   "time" 
  6. func main() { 
  7.   runtime.GOMAXPROCS(1) 
  8.   go func() { 
  9.     for { 
  10.     } 
  11.   }() 
  12.   time.Sleep(time.Millisecond) 
  13.   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。

  1. unc retake(now int64) uint32 { 
  2.   n := 0 
  3.   // 防止 allp 數(shù)組發(fā)生變化,除非我們已經(jīng) STW,此鎖將完全沒有人競爭 
  4.   lock(&allpLock) 
  5.   for i := 0; i < len(allp); i++ { 
  6.     _p_ := allp[i] 
  7.     ... 
  8.     pd := &_p_.sysmontick 
  9.     s := _p_.status 
  10.     sysretake := false 
  11.     if s == _Prunning || s == _Psyscall { 
  12.       // 如果 G 運行時時間太長則進行搶占 
  13.       t := int64(_p_.schedtick) 
  14.       if int64(pd.schedtick) != t { 
  15.         pd.schedtick = uint32(t) 
  16.         pd.schedwhen = now 
  17.       } else if pd.schedwhen+forcePreemptNS <= now { 
  18.         ... 
  19.         sysretake = true 
  20.       } 
  21.     } 
  22.     // 對阻塞在系統(tǒng)調(diào)用上的 P 進行搶占 
  23.     if s == _Psyscall { 
  24.       // 如果已經(jīng)超過了一個系統(tǒng)監(jiān)控的 tick(20us),則從系統(tǒng)調(diào)用中搶占 P 
  25.       t := int64(_p_.syscalltick) 
  26.       if !sysretake && int64(pd.syscalltick) != t { 
  27.         pd.syscalltick = uint32(t) 
  28.         pd.syscallwhen = now 
  29.         continue 
  30.       } 
  31.       // 一方面,在沒有其他 work 的情況下,我們不希望搶奪 P 
  32.       // 另一方面,因為它可能阻止 sysmon 線程從深度睡眠中喚醒,所以最終我們?nèi)韵M麚寠Z P 
  33.       if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now { 
  34.         continue 
  35.       } 
  36.       // 解除 allpLock,從而可以獲取 sched.lock 
  37.       unlock(&allpLock) 
  38.       // 在 CAS 之前需要減少空閑 M 的數(shù)量(假裝某個還在運行) 
  39.       // 否則發(fā)生搶奪的 M 可能退出 syscall 然后再增加 nmidle ,進而發(fā)生死鎖 
  40.       // 這個過程發(fā)生在 stoplockedm 中 
  41.       incidlelocked(-1) 
  42.       if atomic.Cas(&_p_.status, s, _Pidle) { // 將 P 設(shè)為 idle,從而交與其他 M 使用 
  43.         ... 
  44.         n++ 
  45.         _p_.syscalltick++ 
  46.         handoffp(_p_) 
  47.       } 
  48.       incidlelocked(1) 
  49.       lock(&allpLock) 
  50.     } 
  51.   } 
  52.   unlock(&allpLock) 
  53.   return uint32(n) 

在搶占 P 的過程中,有兩個非常小心的處理方式:

  1. 如果此時隊列為空,那么完全沒有必要進行搶占,這時候似乎可以繼續(xù)遍歷其他的 P,但必須在調(diào)度器中自旋的 M 和 空閑的 P 同時存在時、且系統(tǒng)調(diào)用阻塞時間非常長的情況下才能這么做。否則,這個 retake 過程可能返回 0,進而系統(tǒng)監(jiān)控可能看起來像是什么事情也沒做的情況下調(diào)整自己的步調(diào)進入深度睡眠。
  2. 在將 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)生的搶占談起:

  1. func retake(now int64) uint32 { 
  2.   ... 
  3.   for i := 0; i < len(allp); i++ { 
  4.     _p_ := allp[i] 
  5.     ... 
  6.     if s == _Prunning || s == _Psyscall { 
  7.       ... 
  8.       } else if pd.schedwhen+forcePreemptNS <= now { 
  9.         // 對于 syscall 的情況,因為 M 沒有與 P 綁定, 
  10.         // preemptone() 不工作 
  11.         preemptone(_p_) 
  12.         sysretake = true 
  13.       } 
  14.     } 
  15.     ... 
  16.   } 
  17.   ... 
  18. func preemptone(_p_ *p) bool { 
  19.   // 檢查 M 與 P 是否綁定 
  20.   mp := _p_.m.ptr() 
  21.   if mp == nil || mp == getg().m { 
  22.     return false 
  23.   } 
  24.   gp := mp.curg 
  25.   if gp == nil || gp == mp.g0 { 
  26.     return false 
  27.   } 
  28.  
  29.   // 將 G 標記為搶占 
  30.   gp.preempt = true 
  31.  
  32.   // 一個 Goroutine 中的每個調(diào)用都會通過比較當前棧指針和 gp.stackgard0 
  33.   // 來檢查棧是否溢出。 
  34.   // 設(shè)置 gp.stackgard0 為 StackPreempt 來將搶占轉(zhuǎn)換為正常的棧溢出檢查。 
  35.   gp.stackguard0 = stackPreempt 
  36.  
  37.   // 請求該 P 的異步搶占 
  38.   if preemptMSupported && debug.asyncpreemptoff == 0 { 
  39.     _p_.preempt = true 
  40.     preemptM(mp) 
  41.   } 
  42.  
  43.   return true 

搶占信號的選取

preemptM 完成了信號的發(fā)送,其實現(xiàn)也非常直接,直接向需要進行搶占的 M 發(fā)送 SIGURG 信號即可。但是真正的重要的問題是,為什么是 SIGURG 信號而不是其他的信號?如何才能保證該信號不與用戶態(tài)產(chǎn)生的信號產(chǎn)生沖突?這里面有幾個原因:

  1. 默認情況下,SIGURG 已經(jīng)用于調(diào)試器傳遞信號。
  2. SIGURG 可以不加選擇地虛假發(fā)生的信號。例如,我們不能選擇 SIGALRM,因為信號處理程序無法分辨它是否是由實際過程引起的(可以說這意味著信號已損壞)。而常見的用戶自定義信號 SIGUSR1 和 SIGUSR2 也不夠好,因為用戶態(tài)代碼可能會將其進行使用。
  3. 需要處理沒有實時信號的平臺(例如 macOS)。

考慮以上的觀點,SIGURG 其實是一個很好的、滿足所有這些條件、且極不可能因被用戶態(tài)代碼進行使用的一種信號。

  1. const sigPreempt = _SIGURG 
  2.  
  3. // preemptM 向 mp 發(fā)送搶占請求。該請求可以異步處理,也可以與對 M 的其他請求合并。 
  4. // 接收到該請求后,如果正在運行的 G 或 P 被標記為搶占,并且 Goroutine 處于異步安全點, 
  5. // 它將搶占 Goroutine。在處理搶占請求后,它始終以原子方式遞增 mp.preemptGen。 
  6. func preemptM(mp *m) { 
  7.   ... 
  8.   signalM(mp, sigPreempt) 
  9. func signalM(mp *m, sig int) { 
  10.   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ù),這也就為搶占提供了機會。

  1. //go:nowritebarrierrec 
  2. func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) { 
  3.   ... 
  4.   c := &sigctxt{info, ctxt} 
  5.   ... 
  6.   if sig == sigPreempt { 
  7.     // 可能是一個搶占信號 
  8.     doSigPreempt(gp, c) 
  9.     // 即便這是一個搶占信號,它也可能與其他信號進行混合,因此我們 
  10.     // 繼續(xù)進行處理。 
  11.   } 
  12.   ... 
  13. // doSigPreempt 處理了 gp 上的搶占信號 
  14. func doSigPreempt(gp *g, ctxt *sigctxt) { 
  15.   // 檢查 G 是否需要被搶占、搶占是否安全 
  16.   if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) { 
  17.     // 插入搶占調(diào)用 
  18.     ctxt.pushCall(funcPC(asyncPreempt)) 
  19.   } 
  20.  
  21.   // 記錄搶占 
  22.   atomic.Xadd(&gp.m.preemptGen, 1) 

在 ctxt.pushCall 之前,ctxt.rip() 和 ctxt.rep() 都保存了被中斷的 Goroutine 所在的位置,但是 pushCall 直接修改了這些寄存器,進而當從 sighandler 返回用戶態(tài) Goroutine 時,能夠從注入的 asyncPreempt 開始執(zhí)行:

  1. func (c *sigctxt) pushCall(targetPC uintptr) { 
  2.   pc := uintptr(c.rip()) 
  3.   sp := uintptr(c.rsp()) 
  4.   sp -= sys.PtrSize 
  5.   *(*uintptr)(unsafe.Pointer(sp)) = pc 
  6.   c.set_rsp(uint64(sp)) 
  7.   c.set_rip(uint64(targetPC)) 

完成 sighandler 之,我們成功恢復(fù)到 asyncPreempt 調(diào)用:

  1. // asyncPreempt 保存了所有用戶寄存器,并調(diào)用 asyncPreempt2 
  2. // 
  3. // 當棧掃描遭遇 asyncPreempt 棧幀時,將會保守的掃描調(diào)用方棧幀 
  4. func asyncPreempt() 

該函數(shù)的主要目的是保存用戶態(tài)寄存器,并且在調(diào)用完畢前恢復(fù)所有的寄存器上下文就好像什么事情都沒有發(fā)生過一樣:

  1. TEXT ·asyncPreempt(SB),NOSPLIT|NOFRAME,$0-0 
  2.     ... 
  3.     MOVQ AX, 0(SP) 
  4.     ... 
  5.     MOVUPS X15, 352(SP) 
  6.     CALL ·asyncPreempt2(SB) 
  7.     MOVUPS 352(SP), X15 
  8.     ... 
  9.     MOVQ 0(SP), AX 
  10.     ... 
  11.     RET 

當調(diào)用 asyncPreempt2 時,會根據(jù) preemptPark 或者 gopreempt_m 重新切換回調(diào)度循環(huán),從而打斷密集循環(huán)的繼續(xù)執(zhí)行。

  1. //go:nosplit 
  2. func asyncPreempt2() { 
  3.   gp := getg() 
  4.   gp.asyncSafePoint = true 
  5.   if gp.preemptStop { 
  6.     mcall(preemptPark) 
  7.   } else { 
  8.     mcall(gopreempt_m) 
  9.   } 
  10.   // 異步搶占過程結(jié)束 
  11.   gp.asyncSafePoint = false 

至此,異步搶占過程結(jié)束。我們總結(jié)一下?lián)屨颊{(diào)用的整體邏輯:

  1. M1 發(fā)送中斷信號(signalM(mp, sigPreempt))
  2. M2 收到信號,操作系統(tǒng)中斷其執(zhí)行代碼,并切換到信號處理函數(shù)(sighandler(signum, info, ctxt, gp))
  3. M2 修改執(zhí)行的上下文,并恢復(fù)到修改后的位置(asyncPreempt)
  4. 重新進入調(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。

責(zé)任編輯:武曉燕 來源: 碼農(nóng)桃花源
相關(guān)推薦

2021-04-15 12:10:42

Go語言Go開發(fā)者

2022-03-31 10:41:35

iOS應(yīng)用提審發(fā)布

2018-03-23 10:30:56

微網(wǎng)關(guān)服務(wù)嚙合微服務(wù)

2023-09-27 09:04:50

2021-09-15 14:52:43

數(shù)字貨幣傳銷虛擬貨幣

2021-08-11 09:37:11

Redis持久化磁盤

2021-08-04 10:15:14

Go路徑語言

2023-09-22 17:36:37

2021-01-28 22:31:33

分組密碼算法

2020-05-22 08:16:07

PONGPONXG-PON

2018-06-07 13:17:12

契約測試單元測試API測試

2022-08-08 08:25:21

Javajar 文件

2021-08-04 09:32:05

Typescript 技巧Partial

2018-11-29 09:13:47

CPU中斷控制器

2019-02-13 14:15:59

Linux版本Fedora

2021-01-29 08:32:21

數(shù)據(jù)結(jié)構(gòu)數(shù)組

2021-02-06 08:34:49

函數(shù)memoize文檔

2023-05-15 08:38:58

模板方法模式

2022-11-01 08:46:20

責(zé)任鏈模式對象

2023-07-06 13:56:14

微軟Skype
點贊
收藏

51CTO技術(shù)棧公眾號