# 6.3 MPG 模型與并發調度單元
我們首先了解一下調度器的設計原則及一些基本概念來建立對調度器較為宏觀的認識。 理解調度器涉及的主要概念包括以下三個:
* G:**G**oroutine,即我們在 Go 程序中使用`go`關鍵字創建的執行體;
* M:**M**achine,或 worker thread,即傳統意義上進程的線程;
* P:**P**rocessor,即一種人為抽象的、用于執行 Go 代碼被要求局部資源。只有當 M 與一個 P 關聯后才能執行 Go 代碼。除非 M 發生阻塞或在進行系統調用時間過長時,沒有與之關聯的 P。
P 的存在不太好理解,我們暫時先記住這個概念,之后再來回顧這個概念。
## 6.3.1 工作線程的暫止和復始
運行時調度器的任務是給不同的工作線程 (worker thread) 分發可供運行的(ready-to-run)Goroutine。 我們不妨設每個工作線程總是貪心的執行所有存在的 Goroutine,那么當運行進程中存在 n 個線程(M),且 每個 M 在某個時刻有且只能調度一個 G。根據抽屜原理,可以很容易的證明這兩條性質:
* 性質 1:當用戶態代碼創建了p(p\>n)p(p\>n)個 G 時,則必定存在p?np?n個 G 尚未被 M 調度執行;
* 性質 2:當用戶態代碼創建的 q (q < n) 時,則必定存在 n-q 個 M 不存在正在調度的 G。
這兩條性質分別決定了工作線程的**暫止(park)**和**復始(unpark)**。
我們不難發現,調度器的設計需要在性質 1 和性質 2 之間進行權衡: 即既要保持足夠的運行工作線程來利用有效硬件并發資源,又要暫止過多的工作線程來節約 CPU 能耗。 如果我們把調度器想象成一個系統,則尋找這個權衡的最優解意味著我們必須求解調度器系統中 每個 M 的狀態,即系統的全局狀態。這是非常困難的,不妨考慮以下兩個難點:
**難點 1: 在多個 M 之間不使用屏障的情況下,得出調度器中多個 M 的全局狀態是不可能的。**
我們都知道計算的局部性原理,為了利用這一原理,調度器所需調度的 G 都會被放在每個 M 自身對應的本地隊列中。 換句話說,每個 M 都無法直接觀察到其他的 M 所具有的 G 的狀態,存在多個 M 之間的共識問題。這本質上就是一個分布式系統。 顯然,每個 M 都能夠連續的獲取自身的狀態,但當它需要獲取整個系統的全局狀態時卻不容易, 原因在于我們沒有一個能夠讓所有線程都同步的時鐘。換句話說, 我們需要依賴屏障來保證多個 M 之間的全局狀態同步。更進一步,在不使用屏障的情況下, 能否利用每個 M 在不同時間中記錄的本地狀態中計算出調度器的全局狀態,或者形式化的說: 能否在快速路徑(fast path)下計算進程集的全局謂詞(global predicates)呢?根據我們在共識技術中的知識,是不可能的。
**難點 2: 為了獲得最佳的線程管理,我們必須獲得未來的信息,即當一個新的 G 即將就緒(ready)時,則不再暫止一個工作線程。**
舉例來說,目前我們的調度器存在 4 個 M,并其中有 3 個 M 正在調度 G,則其中有 1 個 M 處于空閑狀態。 這時為了節約 CPU 能耗,我們希望對這個空閑的 M 進行暫止操作。但是,正當我們完成了對此 M 的暫止操作后, 用戶態代碼正好執行到了需要調度一個新的 G 時,我們又不得不將剛剛暫止的 M 重新啟動,這無疑增加了開銷。 我們當然有理由希望,如果我們能知曉一個程序生命周期中所有的調度信息, 提前知曉什么時候適合對 M 進行暫止自然再好不過了。 盡管我們能夠對程序代碼進行靜態分析,但這顯然是不可能的:考慮一個簡單的 Web 服務端程序,每個用戶請求 到達后會創建一個新的 G 交于調度器進行調度。但請求到達是一個隨機過程,我們只能預測在給定置信區間下 可能到達的請求數,而不能完整知曉所有的調度需求。
那么我們又應該如何設計一個通用且可擴展的調度器呢?我們很容易想到三種平凡的做法:
**設計 1: 集中式管理所有狀態**
顯然這種做法自然是不可取的,在多個并發實體之間集中管理所有狀態這一共享資源,需要鎖的支持, 當并發實體的數量增大時,將限制調度器的可擴展性。
**設計 2**: 每當需要就緒一個 G1 時,都讓出一個 P,直接切換出 G2,再復始一個 M 來執行 G2。
因為復始的 M 可能在下一個瞬間又沒有調度任務,則會發生線程顛簸(thrashing),進而我們又需要暫止這個線程。 另一方面,我們希望在相同的線程內保存維護 G,這種方式還會破壞計算的局部性原理。
**設計 3**: 任何時候當就緒一個 G、也存在一個空閑的 P 時,都復始一個額外的線程,不進行切換。
因為這個額外線程會在沒有檢查任何工作的情況下立即進行暫止,最終導致大量 M 的暫止和復始行為,產生大量開銷。
基于以上考慮,目前的 Go 的調度器實現中設計了工作線程的**自旋(spinning)狀態**:
1. 如果一個工作線程的本地隊列、全局運行隊列或網絡輪詢器中均沒有可調度的任務,則該線程成為自旋線程;
2. 滿足該條件、被復始的線程也被稱為自旋線程,對于這種線程,運行時不做任何事情。
自旋線程在進行暫止之前,會嘗試從任務隊列中尋找任務。當發現任務時,則會切換成非自旋狀態, 開始執行 Goroutine。而找到不到任務時,則進行暫止。
當一個 Goroutine 準備就緒時,會首先檢查自旋線程的數量,而不是去復始一個新的線程。
如果最后一個自旋線程發現工作并且停止自旋時,則復始一個新的自旋線程。 這個方法消除了不合理的線程復始峰值,且同時保證最終的最大 CPU 并行度利用率。
我們可以通過下圖來直觀理解工作線程的狀態轉換:
~~~
如果存在空閑的 P,且存在暫止的 M,并就緒 G
+------+
v |
執行 --> 自旋 --> 暫止
^ |
+--------+
如果發現工作
~~~
總的來說,調度器的方式可以概括為:**如果存在一個空閑的 P 并且沒有自旋狀態的工作線程 M,則當就緒一個 G 時,就復始一個額外的線程 M。**這個方法消除了不合理的線程復始峰值,且同時保證最終的最大 CPU 并行度利用率。
這種設計的實現復雜性表現在進行自旋與非自旋線程狀態轉換時必須非常小心。 這種轉換在提交一個新的 G 時發生競爭,最終導致任何一個工作線程都需要暫止對方。 如果雙方均發生失敗,則會以半靜態 CPU 利用不足而結束調度。
因此,就緒一個 G 的通用流程為:
* 提交一個 G 到 per-P 的本地工作隊列
* 執行 StoreLoad 風格的寫屏障
* 檢查`sched.nmspinning`數量
而從自旋到非自旋轉換的一般流程為:
* 減少`nmspinning`的數量
* 執行 StoreLoad 風格的寫屏障
* 在所有 per-P 本地任務隊列檢查新的工作
當然,此種復雜性在全局任務隊列對全局隊列并不適用的,因為當給一個全局隊列提交工作時, 不進行線程的復始操作。
## 6.3.2 主要結構
我們這個部分簡單來瀏覽一遍 M/P/G 的結構,初次閱讀此結構會感覺虛無縹緲,不知道在看什么。 事實上,我們更應該直接深入調度器相關的代碼來逐個理解每個字段的實際用途。 因此這里僅在每個結構后簡單討論其宏觀作用,用作后文參考。 讀者可以簡單瀏覽各個字段,為其留下一個初步的印象即可。
### M 的結構
M 是 OS 線程的實體。我們介紹幾個比較重要的字段,包括:
* 持有用于執行調度器的 g0
* 持有用于信號處理的 gsignal
* 持有線程本地存儲 tls
* 持有當前正在運行的 curg
* 持有運行 Goroutine 時需要的本地資源 p
* 表示自身的自旋和非自旋狀態 spining
* 管理在它身上執行的 cgo 調用
* 將自己與其他的 M 進行串聯
* 持有當前線程上進行內存分配的本地緩存 mcache
等等其他五十多個字段,包括關于 M 的一些調度統計、調試信息等。
```
// src/runtime/runtime2.go
type m struct {
g0 *g // 用于執行調度指令的 Goroutine
gsignal *g // 處理 signal 的 g
tls [6]uintptr // 線程本地存儲
curg *g // 當前運行的用戶 Goroutine
p puintptr // 執行 go 代碼時持有的 p (如果沒有執行則為 nil)
spinning bool // m 當前沒有運行 work 且正處于尋找 work 的活躍狀態
cgoCallers *cgoCallers // cgo 調用崩潰的 cgo 回溯
alllink *m // 在 allm 上
mcache *mcache
...
}
```
### P 的結構
P 只是處理器的抽象,而非處理器本身,它存在的意義在于實現工作竊取(work stealing)算法。 簡單來說,每個 P 持有一個 G 的本地隊列。
在沒有 P 的情況下,所有的 G 只能放在一個全局的隊列中。 當 M 執行完 G 而沒有 G 可執行時,必須將隊列鎖住從而取值。
當引入了 P 之后,P 持有 G 的本地隊列,而持有 P 的 M 執行完 G 后在 P 本地隊列中沒有 發現其他 G 可以執行時,雖然仍然會先檢查全局隊列、網絡,但這時增加了一個從其他 P 的 隊列偷取(steal)一個 G 來執行的過程。優先級為本地 > 全局 > 網絡 > 偷取。
一個不恰當的比喻:銀行服務臺排隊中身手敏捷的顧客,當一個服務臺隊列空(沒有人)時, 沒有在排隊的顧客(全局)會立刻跑到該窗口,當徹底沒人時在其他隊列排隊的顧客才會迅速 跑到這個沒人的服務臺來,即所謂的偷取。
```
type p struct {
id int32
status uint32 // p 的狀態 pidle/prunning/...
link puintptr
m muintptr // 反向鏈接到關聯的 m (nil 則表示 idle)
mcache *mcache
pcache pageCache
deferpool [5][]*_defer // 不同大小的可用的 defer 結構池
deferpoolbuf [5][32]*_defer
runqhead uint32 // 可運行的 Goroutine 隊列,可無鎖訪問
runqtail uint32
runq [256]guintptr
runnext guintptr
timersLock mutex
timers []*timer
preempt bool
...
}
```
所以整個結構除去 P 的本地 G 隊列外,就是一些統計、調試、GC 輔助的字段了。
### G 的結構
G 既然是 Goroutine,必然需要定義自身的執行棧:
```
type g struct {
stack struct {
lo uintptr
hi uintptr
} // 棧內存:[stack.lo, stack.hi)
stackguard0 uintptr
stackguard1 uintptr
_panic *_panic
_defer *_defer
m *m // 當前的 m
sched gobuf
stktopsp uintptr // 期望 sp 位于棧頂,用于回溯檢查
param unsafe.Pointer // wakeup 喚醒時候傳遞的參數
atomicstatus uint32
goid int64
preempt bool // 搶占信號,stackguard0 = stackpreempt 的副本
timer *timer // 為 time.Sleep 緩存的計時器
...
}
```
除了執行棧之外,還有很多與調試和 profiling 相關的字段。 一個 G 沒有什么黑魔法,無非是將需要執行的函數參數進行了拷貝,保存了要執行的函數體的入口地址,用于執行。
### 調度器`sched`結構
調度器,所有 Goroutine 被調度的核心,存放了調度器持有的全局資源,訪問這些資源需要持有鎖:
* 管理了能夠將 G 和 M 進行綁定的 M 隊列
* 管理了空閑的 P 鏈表(隊列)
* 管理了 G 的全局隊列
* 管理了可被復用的 G 的全局緩存
* 管理了 defer 池
```
type schedt struct {
lock mutex
pidle puintptr // 空閑 p 鏈表
npidle uint32 // 空閑 p 數量
nmspinning uint32 // 自旋狀態的 M 的數量
runq gQueue // 全局 runnable G 隊列
runqsize int32
gFree struct { // 有效 dead G 的全局緩存.
lock mutex
stack gList // 包含棧的 Gs
noStack gList // 沒有棧的 Gs
n int32
}
sudoglock mutex // sudog 結構的集中緩存
sudogcache *sudog
deferlock mutex // 不同大小的有效的 defer 結構的池
deferpool [5]*_defer
...
}
```
我們已經在[5.2 Go 程序啟動引導](https://golang.design/under-the-hood/zh-cn/part1basic/ch05life/boot)中粗略了解到`schedinit`函數, 現在我們來仔細分析里面真正關于調度器的初始化步驟。
```
// runtime/proc.go
func schedinit() {
_g_ := getg()
(...)
// M 初始化
mcommoninit(_g_.m)
(...)
// P 初始化
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}
(...)
}
```
```
TEXT runtime·rt0_go(SB),NOSPLIT,$0
(...)
CALL runtime·schedinit(SB) // M, P 初始化
MOVQ $runtime·mainPC(SB), AX
PUSHQ AX
PUSHQ $0
CALL runtime·newproc(SB) // G 初始化
POPQ AX
POPQ AX
(...)
RET
DATA runtime·mainPC+0(SB)/8,$runtime·main(SB)
GLOBL runtime·mainPC(SB),RODATA,$8
```
**圖 1: MPG 初始化過程。M/P/G 彼此的初始化順序遵循:`mcommoninit`、`procresize`、`newproc`,他們分別負責初始化 M 資源池(`allm`)、P 資源池(`allp`)、G 的運行現場(`g.sched`)以及調度隊列(`p.runq`)。**
## 6.3.3 M 初始化
M 其實就是 OS 線程,它只有兩個狀態:自旋、非自旋。 在調度器初始化階段,只有一個 M,那就是主 OS 線程,因此這里的`commoninit`僅僅只是對 M 進行一個初步的初始化, 該初始化包含對 M 及用于處理 M 信號的 G 的相關運算操作,未涉及工作線程的暫止和復始。
```
// src/runtime/proc.go
func mcommoninit(mp *m) {
(...)
lock(&sched.lock)
(...)
// mnext 表示當前 m 的數量,還表示下一個 m 的 id
mp.id = sched.mnext
// 增加 m 的數量
sched.mnext++
(...) // 初始化 gsignal,用于處理 m 上的信號
// 添加到 allm 中,從而當它剛保存到寄存器或本地線程存儲時候 GC 不會釋放 g.m
mp.alllink = allm
// NumCgoCall() 會在沒有使用 schedlock 時遍歷 allm,等價于 allm = mp
atomicstorep(unsafe.Pointer(&allm), unsafe.Pointer(mp))
unlock(&sched.lock)
(...)
}
```
這里省略了對不影響本節內容的`gsignal`的初始化過程,其作用參見[6.5 信號處理機制](https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/signal)。
## 6.3.4 P 初始化
在看`runtime.procresize`函數之前,我們先概覽一遍 P 的狀態轉換圖,如圖 2 所示。
**圖 2: P 的狀態轉換圖**
通常情況下(在程序運行時不調整 P 的個數),P 只會在四種狀態下進行切換。 當程序剛開始運行進行初始化時,所有的 P 都處于`_Pgcstop`狀態, 隨著 P 的初始化(`runtime.procresize`),會被置于`_Pidle`。
當 M 需要運行時,會`runtime.acquirep`,并通過`runtime.releasep`來釋放。 當 G 執行時需要進入系統調用時,P 會被設置為`_Psyscall`, 如果這個時候被系統監控搶奪(`runtime.retake`),則 P 會被重新修改為`_Pidle`。 如果在程序運行中發生 GC,則 P 會被設置為`_Pgcstop`, 并在`runtime.startTheWorld`時重新調整為`_Pidle`或者`_Prunning`。
因為這里我們還在討論初始化過程,我們先只關注`runtime.procresize`這個函數:
```
func procresize(nprocs int32) *p {
// 獲取先前的 P 個數
old := gomaxprocs
(...)
// 更新統計信息,記錄此次修改 gomaxprocs 的時間
now := nanotime()
if sched.procresizetime != 0 {
sched.totaltime += int64(old) * (now - sched.procresizetime)
}
sched.procresizetime = now
// 必要時增加 allp
// 這個時候本質上是在檢查用戶代碼是否有調用過 runtime.MAXGOPROCS 調整 p 的數量
// 此處多一步檢查是為了避免內部的鎖,如果 nprocs 明顯小于 allp 的可見數量(因為 len)
// 則不需要進行加鎖
if nprocs > int32(len(allp)) {
// 此處與 retake 同步,它可以同時運行,因為它不會在 P 上運行。
lock(&allpLock)
if nprocs <= int32(cap(allp)) {
// 如果 nprocs 被調小了,扔掉多余的 p
allp = allp[:nprocs]
} else {
// 否則(調大了)創建更多的 p
nallp := make([]*p, nprocs)
// 將原有的 p 復制到新創建的 new all p 中,不浪費舊的 p
copy(nallp, allp[:cap(allp)])
allp = nallp
}
unlock(&allpLock)
}
// 初始化新的 P
for i := old; i < nprocs; i++ {
pp := allp[i]
// 如果 p 是新創建的(新創建的 p 在數組中為 nil),則申請新的 P 對象
if pp == nil {
pp = new(p)
}
pp.init(i)
atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp)) // allp[i] = pp
}
_g_ := getg()
// 如果當前正在使用的 P 應該被釋放,則更換為 allp[0]
// 否則是初始化階段,沒有 P 綁定當前 P allp[0]
if _g_.m.p != 0 && _g_.m.p.ptr().id < nprocs {
// 繼續使用當前 P
_g_.m.p.ptr().status = _Prunning
(...)
} else {
// 釋放當前 P,因為已失效
if _g_.m.p != 0 {
_g_.m.p.ptr().m = 0
}
_g_.m.p = 0
_g_.m.mcache = nil
// 更換到 allp[0]
p := allp[0]
p.m = 0
p.status = _Pidle
acquirep(p) // 直接將 allp[0] 綁定到當前的 M
(...)
}
// 從未使用的 p 釋放資源
for i := nprocs; i < old; i++ {
p := allp[i]
p.destroy()
// 不能釋放 p 本身,因為他可能在 m 進入系統調用時被引用
}
// 清理完畢后,修剪 allp, nprocs 個數之外的所有 P
if int32(len(allp)) != nprocs {
lock(&allpLock)
allp = allp[:nprocs]
unlock(&allpLock)
}
// 將沒有本地任務的 P 放到空閑鏈表中
var runnablePs *p
for i := nprocs - 1; i >= 0; i-- {
// 挨個檢查 p
p := allp[i]
// 確保不是當前正在使用的 P
if _g_.m.p.ptr() == p {
continue
}
// 將 p 設為 idel
p.status = _Pidle
if runqempty(p) {
// 放入 idle 鏈表
pidleput(p)
} else {
// 如果有本地任務,則為其綁定一個 M
p.m.set(mget())
// 第一個循環為 nil,后續則為上一個 p
// 此處即為構建可運行的 p 鏈表
p.link.set(runnablePs)
runnablePs = p
}
}
stealOrder.reset(uint32(nprocs))
atomic.Store((*uint32)(unsafe.Pointer(gomaxprocs)), uint32(nprocs)) // gomaxprocs = nprocs
return runnablePs // 返回所有包含本地任務的 P 鏈表
}
// 初始化 pp,
func (pp *p) init(id int32) {
// p 的 id 就是它在 allp 中的索引
pp.id = id
// 新創建的 p 處于 _Pgcstop 狀態
pp.status = _Pgcstop
(...)
// 為 P 分配 cache 對象
if pp.mcache == nil {
// 如果 old == 0 且 i == 0 說明這是引導階段初始化第一個 p
if id == 0 {
(...)
pp.mcache = getg().m.mcache // bootstrap
} else {
pp.mcache = allocmcache()
}
}
(...)
}
// 釋放未使用的 P,一般情況下不會執行這段代碼
func (pp *p) destroy() {
// 將所有 runnable Goroutine 移動至全局隊列
for pp.runqhead != pp.runqtail {
// 從本地隊列中 pop
pp.runqtail--
gp := pp.runq[pp.runqtail%uint32(len(pp.runq))].ptr()
// push 到全局隊列中
globrunqputhead(gp)
}
if pp.runnext != 0 {
globrunqputhead(pp.runnext.ptr())
pp.runnext = 0
}
(...)
// 將當前 P 的空閑的 G 復鏈轉移到全局
gfpurge(pp)
(...)
pp.status = _Pdead
}
```
`procresize`這個函數相對較長,我們來總結一下它主要干了什么事情:
1. 調用時已經 STW,記錄調整 P 的時間;
2. 按需調整`allp`的大小;
3. 按需初始化`allp`中的 P;
4. 如果當前的 P 還可以繼續使用(沒有被移除),則將 P 設置為 \_Prunning;
5. 否則將第一個 P 搶過來給當前 G 的 M 進行綁定
6. 從`allp`移除不需要的 P,將釋放的 P 隊列中的任務扔進全局隊列;
7. 最后挨個檢查 P,將沒有任務的 P 放入 idle 隊列
8. 除去當前 P 之外,將有任務的 P 彼此串聯成鏈表,將沒有任務的 P 放回到 idle 鏈表中
顯然,在運行 P 初始化之前,我們剛剛初始化完 M,因此第 7 步中的綁定 M 會將當前的 P 綁定到初始 M 上。 而后由于程序剛剛開始,P 隊列是空的,所以他們都會被鏈接到可運行的 P 鏈表上處于`_Pidle`狀態。
為了向用戶層提供對調度器的控制,runtime 包中提供了一些方法達到了這一目的,在本章的最后, 我們來快速過一遍在前面還未提及的在 runtime 包中一些與調度器相關的公共方法。
### 6.3.1 GOMAXPROCS
我們知道在大部分的時間里,P 的數量是不會被動態調整的。 而`runtime.GOMAXPROCS`能夠在運行時動態調整 P 的數量,我們就來看看這個調用會做什么事情。
它的代碼非常簡單:
```
// GOMAXPROCS 設置能夠同時執行線程的最大 CPU 數,并返回原先的設定。
// 如果 n < 1,則他不會進行任何修改。
// 機器上的邏輯 CPU 的個數可以從 NumCPU 調用上獲取。
// 該調用會在調度器進行改進后被移除。
func GOMAXPROCS(n int) int {
...
// 當調整 P 的數量時,調度器會被鎖住
lock(&sched.lock)
ret := int(gomaxprocs)
unlock(&sched.lock)
// 返回原有設置
if n <= 0 || n == ret {
return ret
}
// 停止一切事物,將 STW 的原因設置為 P 被調整
stopTheWorld("GOMAXPROCS")
// STW 后,修改 P 的數量
newprocs = int32(n)
// 重新恢復
// 在這個過程中,startTheWorld 會調用 procresize 進而動態的調整 P 的數量
startTheWorld()
return ret
}
```
可以看到,`GOMAXPROCS`從一出生似乎就被判了死刑,官方的注釋已經明確的說明了這個調用 在后續改進調度器后會被移除。
它的過程也非常簡單粗暴,調用他必須付出 STW 這種極大的代價。 當 P 被調整為小于 1 或與原有值相同時候,不會產生任何效果,例如:
1
runtime.GOMAXPROCS(runtime.GOMAXPROCS(0))
## 6.3.5 G 初始化
運行完`runtime.procresize`之后,我們知道,主 Goroutine 會以被調度器調度的方式進行運行, 這將由`runtime.newproc`來完成主 Goroutine 的初始化工作。
在看`runtime.newproc`之前,我們先大致瀏覽一下 G 的各個狀態,如圖 3 所示。
**圖 3: G 的轉狀態轉換圖**
我們接下來就來粗略看一看`runtime.newproc`:
```
//go:nosplit
func newproc(siz int32, fn *funcval) {
// 從 fn 的地址增加一個指針的長度,從而獲取第一參數地址
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg()
pc := getcallerpc() // 獲取調用方 PC/IP 寄存器值
// 用 g0 系統棧創建 Goroutine 對象
// 傳遞的參數包括 fn 函數入口地址, argp 參數起始地址, siz 參數長度, gp(g0),調用方 pc(goroutine)
systemstack(func() {
newproc1(fn, (*uint8)(argp), siz, gp, pc)
})
}
type funcval struct {
fn uintptr
// 變長大小,fn 的數據在應在 fn 之后
}
// getcallerpc 返回它調用方的調用方程序計數器 PC program conter
//go:noescape
func getcallerpc() uintptr
```
詳細的參數獲取過程需要編譯器的配合,也是實現 Goroutine 的關鍵。我們來看一下具體的傳參過程:
```
package main
func hello(msg string) {
println(msg)
}
func main() {
go hello("hello world")
}
```
```
LEAQ go.string.*+1874(SB), AX // 將 "hello world" 的地址給 AX
MOVQ AX, 0x10(SP) // 將 AX 的值放到 0x10
MOVL $0x10, 0(SP) // 將最后一個參數的位置存到棧頂 0x00
LEAQ go.func.*+67(SB), AX // 將 go 語句調用的函數入口地址給 AX
MOVQ AX, 0x8(SP) // 將 AX 存入 0x08
CALL runtime.newproc(SB) // 調用 newproc
```
這個過程里我們基本上可以看到棧是這樣排布的:
~~~
棧布局
| | 高地址
| |
+-----------------+
| &"hello world" |
0x10 +-----------------+ <--- fn + sys.PtrSize
| hello |
0x08 +-----------------+ <--- fn
| siz |
0x00 +-----------------+ SP
| newproc PC |
+-----------------+ callerpc: 要運行的 Goroutine 的 PC
| |
| | 低地址
~~~
從而當`newproc`開始運行時,先獲得 siz 作為第一個參數,再獲得 fn 作為第二個參數, 然后通過`add`計算出`fn`參數開始的位置。
現在我們知道`newproc`會獲取需要執行的 Goroutine 要執行的函數體的地址、 參數起始地址、參數長度、以及 Goroutine 的調用地址。 然后在 g0 系統棧上通過`newproc1`創建并初始化新的 Goroutine ,下面我們來看`newproc1`。
```
// 創建一個運行 fn 的新 g,具有 narg 字節大小的參數,從 argp 開始。
// callerps 是 go 語句的起始地址。新創建的 g 會被放入 g 的隊列中等待運行。
func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) {
_g_ := getg() // 因為是在系統棧運行所以此時的 g 為 g0
(...)
_g_.m.locks++ // 禁止這時 g 的 m 被搶占因為它可以在一個局部變量中保存 p
siz := narg
siz = (siz + 7) &^ 7
(...)
// 獲得 p
_p_ := _g_.m.p.ptr()
// 根據 p 獲得一個新的 g
newg := gfget(_p_)
// 初始化階段,gfget 是不可能找到 g 的
// 也可能運行中本來就已經耗盡了
if newg == nil {
// 創建一個擁有 _StackMin 大小的棧的 g
newg = malg(_StackMin)
// 將新創建的 g 從 _Gidle 更新為 _Gdead 狀態
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // 將 Gdead 狀態的 g 添加到 allg,這樣 GC 不會掃描未初始化的棧
}
(...)
// 計算運行空間大小,對齊
totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize // extra space in case of reads slightly beyond frame
totalSize += -totalSize & (sys.SpAlign - 1) // align to spAlign
// 確定 sp 和參數入棧位置
sp := newg.stack.hi - totalSize
spArg := sp
(...)
// 處理參數,當有參數時,將參數拷貝到 Goroutine 的執行棧中
if narg > 0 {
// 從 argp 參數開始的位置,復制 narg 個字節到 spArg(參數拷貝)
memmove(unsafe.Pointer(spArg), unsafe.Pointer(argp), uintptr(narg))
// 棧到棧的拷貝。
// 如果啟用了 write barrier 并且 源棧為灰色(目標始終為黑色),
// 則執行 barrier 拷貝。
// 因為目標棧上可能有垃圾,我們在 memmove 之后執行此操作。
if writeBarrier.needed && !_g_.m.curg.gcscandone {
f := findfunc(fn.fn)
stkmap := (*stackmap)(funcdata(f, _FUNCDATA_ArgsPointerMaps))
if stkmap.nbit > 0 {
// 我們正位于序言部分,因此棧 map 索引總是 0
bv := stackmapdata(stkmap, 0)
bulkBarrierBitmap(spArg, spArg, uintptr(bv.n)*sys.PtrSize, 0, bv.bytedata)
}
}
}
// 清理、創建并初始化的 g 的運行現場
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
newg.sched.sp = sp
newg.stktopsp = sp
newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum 從而前一個指令還在相同的函數內
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)
// 初始化 g 的基本狀態
newg.gopc = callerpc
newg.ancestors = saveAncestors(callergp) // 調試相關,追蹤調用方
newg.startpc = fn.fn // 入口 pc
(...)
newg.gcscanvalid = false
// 現在將 g 更換為 _Grunnable 狀態
casgstatus(newg, _Gdead, _Grunnable)
// 分配 goid
if _p_.goidcache == _p_.goidcacheend {
// Sched.goidgen 為最后一個分配的 id,相當于一個全局計數器
// 這一批必須為 [sched.goidgen+1, sched.goidgen+GoidCacheBatch].
// 啟動時 sched.goidgen=0, 因此主 Goroutine 的 goid 為 1
_p_.goidcache = atomic.Xadd64(&sched.goidgen, _GoidCacheBatch)
_p_.goidcache -= _GoidCacheBatch - 1
_p_.goidcacheend = _p_.goidcache + _GoidCacheBatch
}
newg.goid = int64(_p_.goidcache)
_p_.goidcache++
(...)
// 將這里新創建的 g 放入 p 的本地隊列或直接放入全局隊列
// true 表示放入執行隊列的下一個,false 表示放入隊尾
runqput(_p_, newg, true)
// 如果有空閑的 P、且 spinning 的 M 數量為 0,且主 Goroutine 已經開始運行,則進行喚醒 p
// 初始化階段 mainStarted 為 false,所以 p 不會被喚醒
if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted {
wakep()
}
releasem(_g_.m)
}
//go:nosplit
func releasem(mp *m) {
_g_ := getg()
mp.locks--
if mp.locks == 0 && _g_.preempt {
// 如果我們在 newstack 中清除了搶占請求,則恢復搶占請求
_g_.stackguard0 = stackPreempt
}
}
```
創建 G 的過程也是相對比較復雜的,我們來總結一下這個過程:
1. 首先嘗試從 P 本地 gfree 鏈表或全局 gfree 隊列獲取已經執行過的 g
2. 初始化過程中程序無論是本地隊列還是全局隊列都不可能獲取到 g,因此創建一個新的 g,并為其分配運行線程(執行棧),這時 g 處于`_Gidle`狀態
3. 創建完成后,g 被更改為`_Gdead`狀態,并根據要執行函數的入口地址和參數,初始化執行棧的 SP 和參數的入棧位置,并將需要的參數拷貝一份存入執行棧中
4. 根據 SP、參數,在`g.sched`中保存 SP 和 PC 指針來初始化 g 的運行現場
5. 將調用方、要執行的函數的入口 PC 進行保存,并將 g 的狀態更改為`_Grunnable`
6. 給 Goroutine 分配 id,并將其放入 P 本地隊列的隊頭或全局隊列(初始化階段隊列肯定不是滿的,因此不可能放入全局隊列)
7. 檢查空閑的 P,將其喚醒,準備執行 G,但我們目前處于初始化階段,主 Goroutine 尚未開始執行,因此這里不會喚醒 P。
值得一提的是,`newproc`是由`go:nosplit`修飾的函數(見[6.8 協作與搶占](https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/preemption)), 因此這個函數在執行過程中不會發生擴張和搶占,這個函數中的每一行代碼都是深思熟慮過、確保能夠在有限的棧空間內 完成執行。
## 小結
我們已經分析完了整個運行鏈條:`mcommoninit`–>`procresize`–>`newproc`。
在調度器的初始化過程中,首先通過`mcommoninit`對 M 的信號 G 進行初始化。 而后通過`procresize`創建與 CPU 核心數 (或與用戶指定的 GOMAXPROCS) 相同的 P。 最后通過`newproc`創建包含可以運行要執行函數的執行棧、運行現場的 G,并將創建的 G 放入剛創建好的 P 的本地可運行隊列(第一個入隊的 G,也就是主 Goroutine 要執行的函數體), 完成 G 的創建。
調度器的設計還是相當巧妙的。它通過引入一個 P,巧妙的減緩了全局鎖的調用頻率,進一步壓榨了機器的性能。 Goroutine 本身也不是什么黑魔法,運行時只是將其作為一個需要運行的入口地址保存在了 G 中, 同時對調用的參數進行了一份拷貝。我們說 P 是處理器自身的抽象,但 P 只是一個純粹的概念。相反,M 才是運行代碼的真身。
- 第一部分 :基礎篇
- 第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去向何方?