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