# 6.8 協作與搶占
我們在[6.3 調度循環](https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/exec)一節中遺留過一個未解答的問題: 如果某個 G 執行時間過長,其他的 G 如何才能被正常的調度? 這便涉及到有關調度的兩個理念:協作式調度與搶占式調度。
協作式和搶占式這兩個理念解釋起來很簡單: 協作式調度依靠被調度方主動棄權;搶占式調度則依靠調度器強制將被調度方被動中斷。 這兩個概念其實描述了調度的兩種截然不同的策略,這兩種決策模式,在調度理論中其實已經研究得很透徹了。
Go 的運行時并不具備操作系統內核級的硬件中斷能力,基于工作竊取的調度器實現,本質上屬于 先來先服務的協作式調度,為了解決響應時間可能較高的問題,目前運行時實現了兩種不同的調度策略、 每種策略各兩個形式。保證在大部分情況下,不同的 G 能夠獲得均勻的時間片:
* 同步協作式調度
1. 主動用戶讓權:通過`runtime.Gosched`調用主動讓出執行機會;
2. 主動調度棄權:當發生執行棧分段時,檢查自身的搶占標記,決定是否繼續執行;
* 異步搶占式調度
1. 被動監控搶占:當 G 阻塞在 M 上時(系統調用、channel 等),系統監控會將 P 從 M 上搶奪并分配給其他的 M 來執行其他的 G,而位于被搶奪 P 的 M 本地調度隊列中 的 G 則可能會被偷取到其他 M 中。
2. 被動 GC 搶占:當需要進行垃圾回收時,為了保證不具備主動搶占處理的函數執行時間過長,導致 導致垃圾回收遲遲不得執行而導致的高延遲,而強制停止 G 并轉為執行垃圾回收。
## 6.8.1 協作式調度
### 主動用戶讓權: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 上的執行權利,轉去執行其他的 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 執行棧管理](https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/stack)一節中我們已經了解到,函數調用的序言部分 會檢查 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 驗證了可以被搶占的條件:
1. 運行時**沒有**禁止搶占(`m.locks == 0`)
2. 運行時**沒有**在執行內存分配(`m.mallocing == 0`)
3. 運行時**沒有**關閉搶占機制(`m.preemptoff == ""`)
4. 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`呢? 一共有以下幾種情況:
1. 進入系統調用時(`runtime.reentersyscall`,注意這種情況是為了保證不會發生棧分裂, 真正的搶占是異步的通過系統監控進行的)
2. 任何運行時不再持有鎖的時候(`m.locks == 0`)
3. 當垃圾回收器需要停止所有用戶 Goroutine 時
## 6.8.2 搶占式調度
從上面提到的兩種協作式調度邏輯我們可以看出,這種需要用戶代碼來主動配合的調度方式存在 一些致命的缺陷:一個沒有主動放棄執行權、且不參與任何函數調用的函數,直到執行完畢之前, 是不會被搶占的。那么這種不會被搶占的函數會導致什么嚴重的問題呢?回答是,由于運行時無法 停止該用戶代碼,則當需要進行垃圾回收時,無法及時進行;對于一些實時性要求較高的用戶態 Goroutine 而言,也久久得不到調度。我們這里不去深入討論垃圾回收的具體細節,讀者將垃圾回收器 一章中詳細看到這類問題導致的后果。單從調度的角度而言,我們直接來看一個非常簡單的例子:
```
// 此程序在 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 循環搶占,通過編譯器輔助的方式,插入搶占檢查指令, 與流程圖回邊(指節點被訪問過但其子節點尚未訪問完畢)**安全點**(在一個線程執行中,垃圾回收器 能夠識別所有對象引用狀態的一個狀態,我們將在[8.9 安全點分析](https://golang.design/under-the-hood/zh-cn/part2runtime/ch08gc/safe)一節中進一步深入討論這一狀態)的方式進行解決。
盡管此舉能為搶占帶來顯著的提升,但是在一個循環中引入分支顯然會降低性能。 盡管隨后 David Chase 對這個方法進行了改進,僅在插入了一條 TESTB 指令 \[Chase, 2017\], 在完全沒有分支以及寄存器壓力的情況下,仍然造成了幾何平均 7.8% 的性能損失。 這種結果其實是情理之中的,很多需要進行密集循環的計算時間都是在運行時才能確定的, 直接由編譯器檢測這類密集循環而插入額外的指令可想而知是欠妥的做法。
終于在 Go 1.10 后 \[Clements, 2019\],Austin 進一步提出的解決方案,希望使用每個指令 與執行棧和寄存器的映射關系,通過記錄足夠多的信息,并通過異步線程來發送搶占信號的方式 來支持異步搶占式調度。
我們知道現代操作系統的調度器多為搶占式調度,其實現方式通過硬件中斷來支持線程的切換, 進而能安全的保存運行上下文。在 Go 運行時實現搶占式調度同樣也可以使用類似的方式,通過 向線程發送系統信號的方式來中斷 M 的執行,進而達到搶占的目的。 但與操作系統的不同之處在于,由于運行時諸多機制的存在(例如垃圾回收器),還必須能夠在 Goroutine 被停止時,保存充足的上下文信息。 這就給中斷信號帶來了麻煩,如果中斷信號恰好發生在一些關鍵階段(例如寫屏障期間), 則無法保證程序的正確性。這也就要求我們需要嚴格考慮觸發異步搶占的時機。
異步搶占式調度的一種方式就與運行時系統監控有關,監控循環會將發生阻塞的 Goroutine 搶占, 解綁 P 與 M,從而讓其他的線程能夠獲得 P 繼續執行其他的 Goroutine。 這得益于`sysmon`中調用的`retake`方法。這個方法處理了兩種搶占情況, 一是搶占阻塞在系統調用上的 P,二是搶占運行時間過長的 G。 其中搶占運行時間過長的 G 這一方式還會出現在垃圾回收需要進入 STW 時。
### P 搶占
我們先來看搶占阻塞在系統調用上的 G 這種情況。這種搶占的實現方法非常的自然,因為 Goroutine 已經阻塞在了系統調用上,我們可以非常安全的將 M 與 P 進行解綁,即便是 Goroutine 從阻塞中恢復,也會檢查自身所在的 M 是否仍然持有 P,如果沒有 P 則重新考慮 與可用的 P 進行綁定。這種異步搶占的本質是:搶占 P。
```
func 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 的過程中,有兩個非常小心的處理方式:
1. 如果此時隊列為空,那么完全沒有必要進行搶占,這時候似乎可以繼續遍歷其他的 P, 但必須在調度器中自旋的 M 和 空閑的 P 同時存在時、且系統調用阻塞時間非常長的情況下 才能這么做。否則,這個 retake 過程可能返回 0,進而系統監控可能看起來像是什么事情 也沒做的情況下調整自己的步調進入深度睡眠。
2. 在將 P 設置為空閑狀態前,必須先將 M 的數量減少,否則當 M 退出系統調用時, 會在`exitsyscall0`中調用`stoplockedm`從而增加空閑 M 的數量,進而發生死鎖。
### M 搶占
在 P 搶占的循環中,我們沒有討論一個細節:即在檢查 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 信號而不是其他的信號?如何才能保證該信號 不與用戶態產生的信號產生沖突?這里面有幾個原因:
1. 默認情況下,SIGURG 已經用于調試器傳遞信號。
2. SIGRUURG 可以不加選擇地虛假發生的信號。例如,我們不能選擇 SIGALRM,因為 信號處理程序無法分辨它是否是由實際過程引起的(可以說這意味著信號已損壞)。 而常見的用戶自定義信號 SIGUSR1 和 SIGUSR2 也不夠好,因為用戶態代碼可能會將其進行使用
3. 需要處理沒有實時信號的平臺(例如 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)
}
```
#### 搶占調用的注入
我們在信號處理一節中已經知道,每個運行的 M 都會設置一個系統信號的處理的回調,當出現系統 信號時,操作系統將負責將運行代碼進行中斷,并安全的保護其執行現場,進而 Go 運行時能 將針對信號的類型進行處理,當信號處理函數執行結束后,程序會再次進入內核空間,進而恢復到 被中斷的位置。
但是這里面又一個很巧妙的用法,在[6.5 信號處理機制](https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/signal)一節中我們已經介紹了 Go 運行時進行信號處理的基本做法,其核心是注冊`sighandler`函數,并在信號到達后, 由操作系統中斷轉入內核空間,而后將所中斷線程的執行上下文參數(例如寄存器`rip`,`rep`等) 傳遞給處理函數。如果在`sighandler`中修改了這個上下文參數,操作系統則會根據修改后的 上下文信息恢復執行,這也就為搶占提供了機會。
```
//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
}
```
至此,異步搶占過程結束。我們總結一下搶占調用的整體邏輯:
1. M1 發送中斷信號(`signalM(mp, sigPreempt)`)
2. M2 收到信號,操作系統中斷其執行代碼,并切換到信號處理函數(`sighandler(signum, info, ctxt, gp)`)
3. M2 修改執行的上下文,并恢復到修改后的位置(`asyncPreempt`)
4. 重新進入調度循環進而調度其他 Goroutine(`preemptPark`和`gopreempt_m`)
上述的異步搶占流程我們是通過系統監控來說明的,正如前面所提及的,異步搶占的本質是在為垃圾回收器服務, 由于我們還沒有討論過 Go 語言垃圾回收的具體細節,這里便不做過多展開,讀者只需理解,在垃圾回收周期開始時, 垃圾回收器將通過上述異步搶占的邏輯,停止所有用戶 Goroutine,進而轉去執行垃圾回收。
## 小結
總的來說,應用層的調度策略不易實現,因此實現上也并不是特別緊急。我們回顧 Go 語言調度策略的演變過程 不難發現,實現它們的動力是從實際需求出發的。Go 語言從設計之初并沒有刻意的去考慮對 Goroutine 的搶占機制。 從早期無法對 Goroutine 進行搶占的原始時代,到現在的協作與搶占同時配合的調度策略, 其問題的核心是垃圾回收等運行時機制的需要。
當運行時需要執行垃圾回收時,協作式調度能夠保證具備函數調用的用戶 Goroutine 正常停止; 搶占式調度則能避免由于死循環導致的任意時間的垃圾回收延遲。有了這兩種不同的調度策略, Go 語言的用戶可以放心的寫出各種形式的代碼邏輯,即使運行時垃圾回收也能夠在適當的時候及時中斷用戶代碼, 不至于導致整個系統進入不可預測的停頓。
- 第一部分 :基礎篇
- 第1章 Go語言的前世今生
- 1.2 Go語言綜述
- 1.3 順序進程通訊
- 1.4 Plan9匯編語言
- 第2章 程序生命周期
- 2.1 從go命令談起
- 2.2 Go程序編譯流程
- 2.3 Go 程序啟動引導
- 2.4 主Goroutine的生與死
- 第3 章 語言核心
- 3.1 數組.切片與字符串
- 3.2 散列表
- 3.3 函數調用
- 3.4 延遲語句
- 3.5 恐慌與恢復內建函數
- 3.6 通信原語
- 3.7 接口
- 3.8 運行時類型系統
- 3.9 類型別名
- 3.10 進一步閱讀的參考文獻
- 第4章 錯誤
- 4.1 問題的演化
- 4.2 錯誤值檢查
- 4.3 錯誤格式與上下文
- 4.4 錯誤語義
- 4.5 錯誤處理的未來
- 4.6 進一步閱讀的參考文獻
- 第5章 同步模式
- 5.1 共享內存式同步模式
- 5.2 互斥鎖
- 5.3 原子操作
- 5.4 條件變量
- 5.5 同步組
- 5.6 緩存池
- 5.7 并發安全散列表
- 5.8 上下文
- 5.9 內存一致模型
- 5.10 進一步閱讀的文獻參考
- 第二部分 運行時篇
- 第6章 并發調度
- 6.1 隨機調度的基本概念
- 6.2 工作竊取式調度
- 6.3 MPG模型與并發調度單
- 6.4 調度循環
- 6.5 線程管理
- 6.6 信號處理機制
- 6.7 執行棧管理
- 6.8 協作與搶占
- 6.9 系統監控
- 6.10 網絡輪詢器
- 6.11 計時器
- 6.12 非均勻訪存下的調度模型
- 6.13 進一步閱讀的參考文獻
- 第7章 內存分配
- 7.1 設計原則
- 7.2 組件
- 7.3 初始化
- 7.4 大對象分配
- 7.5 小對象分配
- 7.6 微對象分配
- 7.7 頁分配器
- 7.8 內存統計
- 第8章 垃圾回收
- 8.1 垃圾回收的基本想法
- 8.2 寫屏幕技術
- 8.3 調步模型與強弱觸發邊界
- 8.4 掃描標記與標記輔助
- 8.5 免清掃式位圖技術
- 8.6 前進保障與終止檢測
- 8.7 安全點分析
- 8.8 分代假設與代際回收
- 8.9 請求假設與實務制導回收
- 8.10 終結器
- 8.11 過去,現在與未來
- 8.12 垃圾回收統一理論
- 8.13 進一步閱讀的參考文獻
- 第三部分 工具鏈篇
- 第9章 代碼分析
- 9.1 死鎖檢測
- 9.2 競爭檢測
- 9.3 性能追蹤
- 9.4 代碼測試
- 9.5 基準測試
- 9.6 運行時統計量
- 9.7 語言服務協議
- 第10章 依賴管理
- 10.1 依賴管理的難點
- 10.2 語義化版本管理
- 10.3 最小版本選擇算法
- 10.4 Vgo 與dep之爭
- 第12章 泛型
- 12.1 泛型設計的演進
- 12.2 基于合約的泛型
- 12.3 類型檢查技術
- 12.4 泛型的未來
- 12.5 進一步閱讀的的參考文獻
- 第13章 編譯技術
- 13.1 詞法與文法
- 13.2 中間表示
- 13.3 優化器
- 13.4 指針檢查器
- 13.5 逃逸分析
- 13.6 自舉
- 13.7 鏈接器
- 13.8 匯編器
- 13.9 調用規約
- 13.10 cgo與系統調用
- 結束語: Go去向何方?