我所理解的 Go 的 GPM 模型
Go 語(yǔ)言(Golang)的一大顯著特性是在其語(yǔ)法層面就內(nèi)建了對(duì)協(xié)程,即 goroutine 的支持,并且其運(yùn)行時(shí)(runtime)系統(tǒng)為這一功能提供了強(qiáng)大且原生的支撐。在我看來(lái),選擇使用協(xié)程而非傳統(tǒng)的線程來(lái)支持高并發(fā)任務(wù),帶來(lái)了諸多益處:
- 切換成本更低 :協(xié)程的切換是純用戶態(tài)的操作,由 Go runtime 直接控制,避免了線程切換時(shí)需要在內(nèi)核態(tài)和用戶態(tài)之間傳遞上下文信息的開銷。相比之下,線程切換由操作系統(tǒng)(OS)層面實(shí)現(xiàn),成本更高。
- 調(diào)度靈活性 :goroutine 的調(diào)度由 Go runtime 決定,而非操作系統(tǒng)。這使得 Go 可以根據(jù)應(yīng)用特性實(shí)現(xiàn)更優(yōu)化的調(diào)度策略。
- 支持大規(guī)模并發(fā) :由于協(xié)程在用戶態(tài)實(shí)現(xiàn)且資源占用小(例如,初始棧空間通常只有幾 KB),因此可以輕松創(chuàng)建和管理成千上萬(wàn)甚至數(shù)百萬(wàn)的 goroutine,遠(yuǎn)超傳統(tǒng)線程所能支持的并發(fā)量。
- 創(chuàng)建與銷毀成本低 :goroutine 的創(chuàng)建和銷毀由 Go runtime 管理,其開銷遠(yuǎn)小于操作系統(tǒng)線程。它們的棧空間是動(dòng)態(tài)伸縮的,初始分配很小,按需增長(zhǎng),回收也更高效。
- 簡(jiǎn)化的并發(fā)編程模型 :通過(guò)
channel
和select
等機(jī)制,goroutine 使得編寫和理解并發(fā)邏輯更為簡(jiǎn)單和安全,減少了對(duì)傳統(tǒng)并發(fā)編程中復(fù)雜鎖機(jī)制的依賴。
然而,這些輕量級(jí)的 goroutine 終究需要依托實(shí)際的操作系統(tǒng)線程才能在 CPU 上執(zhí)行。Go 語(yǔ)言是如何高效管理這些 goroutine 的呢?這就引出了我們今天要深入探討的核心機(jī)制—— GPM 模型 。
總體談?wù)?GPM
GPM 是 Go 調(diào)度器中三個(gè)核心組件的縮寫:
- G (Goroutine) :即 Go 協(xié)程。它是 Go 程序中并發(fā)執(zhí)行的基本單元,擁有自己的棧空間、指令指針以及其他用于調(diào)度和執(zhí)行的上下文信息。G 的數(shù)量可以非常龐大。
- P (Processor) :邏輯處理器。P 并非指物理 CPU 核心,而是 Go runtime 中的一個(gè)概念,它代表了 M (內(nèi)核線程) 執(zhí)行 Go 代碼所需要的上下文和資源,例如本地可運(yùn)行 G 的隊(duì)列(Local Run Queue, LRQ)、內(nèi)存分配狀態(tài)等。每個(gè) P 同時(shí)只能運(yùn)行一個(gè) G。P 的數(shù)量通常由環(huán)境變量
GOMAXPROCS
決定,默認(rèn)情況下等于可用的 CPU 核心數(shù)。 - M (Machine) :內(nèi)核線程,即操作系統(tǒng)管理的線程。M 是實(shí)際執(zhí)行 Go 代碼的實(shí)體。一個(gè) M 必須與一個(gè) P 關(guān)聯(lián)才能執(zhí)行 G。
我們首先從宏觀層面理解這種設(shè)計(jì)背后的考量:
通過(guò)設(shè)定 GOMAXPROCS
來(lái)控制 P 的數(shù)量,Go 程序既能確保充分利用多核 CPU 的計(jì)算能力,又避免了因過(guò)多線程競(jìng)爭(zhēng) CPU 資源而導(dǎo)致的性能下降。通常,P 的數(shù)量與 CPU 核心數(shù)相等,這意味著在理想情況下,每個(gè)核心都有一個(gè) P 在積極地調(diào)度和執(zhí)行 G。
P 的角色至關(guān)重要,它作為 G 和 M 之間的橋梁。P 持有一個(gè)本地可運(yùn)行 G 的隊(duì)列 (LRQ),當(dāng) M 需要執(zhí)行任務(wù)時(shí),它會(huì)從其關(guān)聯(lián)的 P 的 LRQ 中獲取 G 來(lái)執(zhí)行。這種設(shè)計(jì)使得 G 的調(diào)度大部分發(fā)生在用戶態(tài),避免了頻繁的內(nèi)核態(tài)切換。
此外,P 與 M 的結(jié)合實(shí)現(xiàn)了線程的復(fù)用。當(dāng)一個(gè) M 因?yàn)閳?zhí)行的 G 進(jìn)行了阻塞性的系統(tǒng)調(diào)用(syscall)而被阻塞時(shí),它所關(guān)聯(lián)的 P 可以被釋放,并被另一個(gè)空閑的 M 或者一個(gè)新創(chuàng)建的 M 獲取,從而繼續(xù)執(zhí)行 P 本地隊(duì)列中的其他 G。這樣就避免了因?yàn)樯贁?shù)阻塞操作導(dǎo)致大量線程閑置,同時(shí)也減少了線程頻繁創(chuàng)建和銷毀的開銷,相當(dāng)于 Go runtime 內(nèi)部實(shí)現(xiàn)了一個(gè)高效的線程池。這一切對(duì)編寫 Go 代碼的用戶來(lái)說(shuō)是透明的。
GPM 是如何調(diào)度的?
要理解 GPM 的調(diào)度機(jī)制,首先需要了解幾個(gè)關(guān)鍵的概念和數(shù)據(jù)結(jié)構(gòu):
- 全局運(yùn)行隊(duì)列 (Global Run Queue, GRQ) :當(dāng) P 的本地運(yùn)行隊(duì)列沒(méi)有空間,或者某些 G(例如從網(wǎng)絡(luò)調(diào)用返回的 G、被搶占的 G)被喚醒或需要重新調(diào)度時(shí),它們可能會(huì)被放入全局運(yùn)行隊(duì)列。
- P 的本地運(yùn)行隊(duì)列 (Local Run Queue, LRQ) :每個(gè) P 都有一個(gè)自己的 LRQ,用于存放待在該 P 上執(zhí)行的 G。M 會(huì)優(yōu)先從其關(guān)聯(lián) P 的 LRQ 中獲取 G。LRQ 的存在減少了對(duì) GRQ 的競(jìng)爭(zhēng),提高了調(diào)度效率。
g0
:每個(gè) M 都有一個(gè)特殊的 goroutine,稱為g0
。g0
擁有自己的棧空間(獨(dú)立于用戶 G 的棧,通常較大),主要用于執(zhí)行調(diào)度相關(guān)的代碼、垃圾回收的輔助工作以及其他運(yùn)行時(shí)任務(wù)。當(dāng) M 需要切換到某個(gè)用戶 G 執(zhí)行時(shí),會(huì)從g0
棧切換到用戶 G 的棧;反之亦然。m.curg
:指向當(dāng)前在 M 上運(yùn)行的用戶 G。- G 的狀態(tài) :Goroutine 在其生命周期中會(huì)經(jīng)歷多種狀態(tài),例如
_Gidle
(閑置,剛被分配還未使用)、_Grunnable
(可運(yùn)行,在運(yùn)行隊(duì)列中等待調(diào)度)、_Grunning
(運(yùn)行中,正在 M 上執(zhí)行)、_Gsyscall
(進(jìn)行系統(tǒng)調(diào)用,M 已與 P 分離)、_Gwaiting
(等待中,如等待 channel 操作、鎖、或定時(shí)器)、_Gdead
(已結(jié)束,資源可回收)、_Gcopystack
(棧復(fù)制中,通常在棧增長(zhǎng)時(shí)發(fā)生)、_Gpreempted
(被搶占,等待重新調(diào)度)。 - P 的狀態(tài) :P 也有不同的狀態(tài),如
_Pidle
(閑置,沒(méi)有 M 與之關(guān)聯(lián)或沒(méi)有可運(yùn)行的 G)、_Prunning
(運(yùn)行中,有 M 與之關(guān)聯(lián)并正在執(zhí)行 G 或調(diào)度代碼)、_Psyscall
(其關(guān)聯(lián)的 M 正在進(jìn)行一個(gè)阻塞的系統(tǒng)調(diào)用,P 本身可能被其他 M 使用)、_Pgcstop
(因垃圾回收而停止)、_Pdead
(不再使用,例如GOMAXPROCS
被調(diào)小時(shí))。
調(diào)度決策在很大程度上是每個(gè) M 各自獨(dú)立在其 g0
棧上執(zhí)行的。當(dāng)一個(gè) M 空閑下來(lái)(例如,其當(dāng)前 G 執(zhí)行完畢或被阻塞),它會(huì)運(yùn)行調(diào)度代碼來(lái)尋找下一個(gè)可運(yùn)行的 G。
- 沒(méi)有單一的“總控”M :Go 的調(diào)度器設(shè)計(jì)上是去中心化的,沒(méi)有一個(gè)特定的 M 作為“總控制器”來(lái)指揮所有其他 M。這種設(shè)計(jì)避免了單點(diǎn)瓶頸,提高了并發(fā)度。
- 協(xié)調(diào)機(jī)制 :盡管調(diào)度是分布式的,但 M 之間通過(guò)一些共享結(jié)構(gòu)和機(jī)制進(jìn)行協(xié)調(diào):
a.全局運(yùn)行隊(duì)列 (GRQ) :為所有 P 提供了一個(gè)共享的 G 來(lái)源。
b.工作竊取 (Work Stealing) :空閑的 M 會(huì)嘗試從其他 P 的 LRQ 中“竊取”任務(wù)。
c.sysmon
后臺(tái)監(jiān)控線程 :這是一個(gè)特殊的 M(不與 P 綁定),它負(fù)責(zé)一些全局性的協(xié)調(diào)任務(wù),比如垃圾回收的觸發(fā)和輔助、網(wǎng)絡(luò)輪詢器(Netpoller)事件的處理(間接影響調(diào)度,通過(guò)將等待 I/O 的 G 變?yōu)榭蛇\(yùn)行狀態(tài))、以及檢測(cè)并搶占長(zhǎng)時(shí)間運(yùn)行的 G。sysmon
更像是一個(gè)維護(hù)者和協(xié)調(diào)者,而非一個(gè)命令下發(fā)者。
d.P 的管理 :Go runtime 負(fù)責(zé)管理 P 的池。當(dāng) M 因系統(tǒng)調(diào)用阻塞時(shí)釋放 P,或當(dāng)有空閑 P 和可運(yùn)行 G 時(shí),runtime 會(huì)嘗試喚醒或創(chuàng)建 M 來(lái)綁定這些 P。
- 這種分布式的調(diào)度配合全局協(xié)調(diào)機(jī)制,使得 Go 的調(diào)度器既高效又具有良好的伸縮性。
接下來(lái),我們通過(guò)幾個(gè)例子來(lái)具體闡述調(diào)度過(guò)程:
1. 基本調(diào)度流程
假設(shè)我們有一個(gè) P0 和一個(gè) M0,P0 的 LRQ 中有 G1。
- 獲取 G :M0 啟動(dòng)后,或者當(dāng)它完成了前一個(gè) G 的執(zhí)行后,它會(huì)首先查看其關(guān)聯(lián)的 P0 的 LRQ。此時(shí),M0 在
g0
棧上執(zhí)行調(diào)度邏輯。 - 執(zhí)行 G :M0 從 P0 的 LRQ 中取出 G1。G1 的狀態(tài)從
_Grunnable
變?yōu)?nbsp;_Grunning
,P0 的狀態(tài)保持或變?yōu)?nbsp;_Prunning
。M0 的m.curg
指向 G1。隨后,M0 從g0
棧切換到 G1 的棧,開始執(zhí)行 G1 的代碼。 - G 執(zhí)行完畢 :當(dāng) G1 執(zhí)行完畢(例如函數(shù)返回),它會(huì)切換回
g0
棧。G1 的狀態(tài)變?yōu)?nbsp;_Gdead
,其資源會(huì)被回收。M0 (在g0
棧上) 接著會(huì)再次嘗試從 P0 的 LRQ 尋找下一個(gè)可運(yùn)行的 G。 - LRQ 為空 :如果 P0 的 LRQ 為空,M0(在
g0
棧上)會(huì)嘗試進(jìn)行 工作竊取 (work-stealing) ,它會(huì)隨機(jī)查看其他 P 的 LRQ,如果發(fā)現(xiàn)有 G,就竊取一半過(guò)來(lái)放到自己的 P0 的 LRQ 中。如果其他 P 的 LRQ 也都為空,M0 會(huì)嘗試從 GRQ 獲取 G。 - GRQ 也為空 :如果 GRQ 也為空,M0 可能會(huì)將 P0 置為
_Pidle
狀態(tài),并解除 M0 與 P0 的關(guān)聯(lián),M0 自身也可能進(jìn)入休眠(park)狀態(tài),等待新的 G 到來(lái)時(shí)被喚醒。或者,M0 會(huì)去自旋(spinning)一段時(shí)間,期望短期內(nèi)有新的 G 產(chǎn)生。
自旋 (spinning) 是指 M 在一個(gè)緊密的循環(huán)中不斷檢查是否有可運(yùn)行的 G,而不立即放棄 CPU。
- CPU 占用 :在自旋期間,M 會(huì)持續(xù)消耗 CPU 資源,如果該 CPU 核心上沒(méi)有其他更高優(yōu)先級(jí)的任務(wù),它可能會(huì)達(dá)到 100% 的占用率。
- 為何自旋 :這是一種以 CPU 時(shí)間換取調(diào)度延遲的策略。如果新的 G 很快就能變?yōu)榭蛇\(yùn)行狀態(tài)(例如,另一個(gè) M 正在處理一個(gè)即將完成的短任務(wù),或者一個(gè) I/O 事件即將觸發(fā)),那么自旋可以避免 M 進(jìn)入休眠和隨后被喚醒所帶來(lái)的開銷(這通常涉及操作系統(tǒng)層面的上下文切換,成本相對(duì)較高)。
- 自旋的條件與限制 :Go runtime 中的自旋不是無(wú)限制的。
a.通常,只有當(dāng)系統(tǒng)中存在其他活躍的 P(意味著其他 M 正在工作,有可能產(chǎn)生新的 G)時(shí),M 才會(huì)進(jìn)入自旋狀態(tài)。如果所有 P 都已空閑,則 M 傾向于直接休眠。
b.同時(shí),runtime 會(huì)限制并發(fā)自旋的 M 的數(shù)量,以避免過(guò)多的 M 同時(shí)無(wú)效自旋。
c.自旋的持續(xù)時(shí)間或迭代次數(shù)是有限的。如果經(jīng)過(guò)短暫的自旋后仍未找到 G,M 將停止自旋,釋放其 P(如果 P 上確實(shí)沒(méi)有 G),并進(jìn)入休眠(park)狀態(tài),將 CPU 讓給其他進(jìn)程或線程。
自旋是一種短期內(nèi)積極尋找任務(wù)的優(yōu)化手段,適用于預(yù)期任務(wù)會(huì)很快出現(xiàn)的場(chǎng)景,以減少調(diào)度開銷,但它確實(shí)會(huì)短暫地增加 CPU 使用率。
在這個(gè)過(guò)程中,P 的狀態(tài)也會(huì)相應(yīng)變化。例如,當(dāng)一個(gè) M 成功與一個(gè) P 綁定并開始查找或執(zhí)行 G 時(shí),P 的狀態(tài)會(huì)是 _Prunning
。如果 P 的 LRQ 和 GRQ 都長(zhǎng)時(shí)間為空,并且沒(méi)有 M 依附于它,它可能進(jìn)入 _Pidle
狀態(tài)。
G 的棧數(shù)據(jù)切換發(fā)生在 M 從 g0
棧切換到用戶 G 的棧,以及從用戶 G 的棧切回 g0
棧時(shí)。這個(gè)切換操作會(huì)保存和恢復(fù)各自的棧指針和寄存器等上下文信息。
2. 棧的伸縮與 P 的競(jìng)爭(zhēng)
- 棧的動(dòng)態(tài)伸縮 :Goroutine 的棧在創(chuàng)建時(shí)通常較小(例如 2KB)。當(dāng) G 執(zhí)行的函數(shù)調(diào)用深度增加,需要的棧空間超過(guò)當(dāng)前大小時(shí),Go runtime 會(huì)觸發(fā)一個(gè)稱為
morestack
的機(jī)制。該機(jī)制會(huì)分配一個(gè)新的、更大的棧段,并將舊棧的內(nèi)容拷貝到新棧段,然后 G 繼續(xù)在新棧上執(zhí)行。這個(gè)過(guò)程對(duì)用戶是透明的。當(dāng)函數(shù)返回,棧使用量減少時(shí),雖然不會(huì)立即縮小,但在垃圾回收期間,如果發(fā)現(xiàn)棧使用率過(guò)低,可能會(huì)進(jìn)行棧的收縮(shrinkstack
)。 - P 的競(jìng)爭(zhēng) :在 Go 程序啟動(dòng)時(shí),會(huì)根據(jù)
GOMAXPROCS
創(chuàng)建相應(yīng)數(shù)量的 P。如果 M 的數(shù)量少于 P 的數(shù)量(例如,某些 M 因?yàn)橄到y(tǒng)調(diào)用阻塞了),或者有空閑的 P 和待運(yùn)行的 G,運(yùn)行時(shí)可能會(huì)喚醒或創(chuàng)建新的 M 來(lái)綁定這些 P。一個(gè) M 必須獲取到一個(gè) P 才能運(yùn)行 Go 代碼。如果所有 P 都在_Prunning
狀態(tài)(即都有 M 在其上運(yùn)行 G),那么新創(chuàng)建的 G 只能進(jìn)入 LRQ 或 GRQ 等待。當(dāng)一個(gè) M 從阻塞的系統(tǒng)調(diào)用返回,或者一個(gè) G 執(zhí)行完畢,它會(huì)嘗試獲取一個(gè) P 來(lái)繼續(xù)執(zhí)行。
3. I/O 操作與網(wǎng)絡(luò)調(diào)度
當(dāng)一個(gè) G (假設(shè)為 Gx,在 M1/P1 上運(yùn)行) 發(fā)起一個(gè)阻塞性的 I/O 操作,比如網(wǎng)絡(luò)讀寫時(shí),情況會(huì)變得特殊:
- 進(jìn)入系統(tǒng)調(diào)用 :Gx 在 M1 上調(diào)用了一個(gè)阻塞的
read
。Go runtime 的syscall
包中的函數(shù)通常會(huì)進(jìn)行特殊處理。M1 會(huì)即將進(jìn)入阻塞狀態(tài)。 - 釋放 P :為了不讓 P1 上的其他 G 被餓死,M1 會(huì)釋放 P1。P1 此時(shí) 通常會(huì)連同其 LRQ 中的 G 一起 ,被移交給一個(gè)其他可用的、處于空閑狀態(tài)的 M (例如 M2,可以是已存在的空閑 M,或者是 runtime 根據(jù)需要新創(chuàng)建的 M 來(lái)接管這個(gè) P)。M1 則帶著 Gx 進(jìn)入阻塞的系統(tǒng)調(diào)用。Gx 的狀態(tài)變?yōu)?nbsp;
_Gsyscall
。
這里需要考慮到調(diào)度器內(nèi)部實(shí)現(xiàn)的復(fù)雜性和一些邊緣情況。核心原則是: LRQ 始終與 P 綁定 。當(dāng) M1 因 Gx 的系統(tǒng)調(diào)用而將要阻塞時(shí),它會(huì)釋放 P1。
- 主要情況 :調(diào)度器會(huì)立即嘗試尋找一個(gè)空閑的 M (M2) 來(lái)接管 P1。如果找到,M2 就綁定 P1,并開始執(zhí)行 P1 的 LRQ 中的 G。這時(shí),P1 及其 LRQ 完整地從 M1 轉(zhuǎn)移到了 M2。
- 沒(méi)有立即可用的 M:如果暫時(shí)沒(méi)有空閑的 M 可以立即接管 P1,P1 會(huì)被放入一個(gè)空閑 P 隊(duì)列 (
pidle
列表)。其 LRQ 中的 G 仍然與 P1 綁定并處于_Grunnable
狀態(tài)。一旦有 M 可用(例如 M1 從系統(tǒng)調(diào)用返回后變?yōu)榭臻e,或者sysmon
檢測(cè)到需要更多 M 并創(chuàng)建/喚醒了一個(gè)),這個(gè) M 就會(huì)從pidle
列表中獲取 P1,并開始執(zhí)行其 LRQ 中的 G。 - 因此,P1 的 LRQ 中的 G 總是和 P1 在一起 。關(guān)鍵在于 P1 由哪個(gè) M 來(lái)服務(wù)。如果 M1 阻塞了,它就不能服務(wù) P1,所以 P1 必須尋找新的 M,或者等待 M 變?yōu)榭捎谩?/span>
- 網(wǎng)絡(luò)輪詢器 (Netpoller) :Go runtime 內(nèi)部維護(hù)了一個(gè)網(wǎng)絡(luò)輪詢器(在 Linux 上通常基于
epoll
,在 macOS 上基于kqueue
,在 Windows 上基于iocp
)。當(dāng) Gx 發(fā)起網(wǎng)絡(luò) I/O 時(shí),其對(duì)應(yīng)的文件描述符會(huì)被注冊(cè)到這個(gè)網(wǎng)絡(luò)輪詢器中。M1 線程本身會(huì)阻塞在系統(tǒng)調(diào)用上(或者對(duì)于非阻塞 I/O,G 會(huì)等待 netpoller 的通知),但它不再持有 P。 - I/O 就緒與喚醒 :當(dāng)網(wǎng)絡(luò)輪詢器檢測(cè)到 Gx 等待的文件描述符上的 I/O 操作就緒(例如數(shù)據(jù)可讀),它會(huì)通知調(diào)度器。Gx 會(huì)被標(biāo)記為
_Grunnable
,并被放回到某個(gè) P 的 LRQ (可能是原來(lái)的 P1,如果它恰好空閑) 或者 GRQ 中。
Go 的標(biāo)準(zhǔn)庫(kù)網(wǎng)絡(luò)操作在底層通常被封裝為非阻塞模式,并與 netpoller 集成。
- 注冊(cè)與等待 :當(dāng) Gx 調(diào)用如
net.Conn.Read()
時(shí),如果數(shù)據(jù)尚未到達(dá),runtime 不會(huì)真的讓 M1 線程阻塞在內(nèi)核的read()
調(diào)用上。相反,它會(huì)將 Gx 的狀態(tài)置為_Gwaiting
,并將與該連接對(duì)應(yīng)的文件描述符 (FD) 注冊(cè)到 netpoller 中,請(qǐng)求 netpoller 在該 FD 可讀時(shí)通知。然后,M1 釋放 P1(或 P1 被其他 M 接管),M1 可以去執(zhí)行其他 G 或者休眠。 - Netpoller 的監(jiān)控 :Netpoller (通常是一個(gè)獨(dú)立的系統(tǒng)線程或由
sysmon
驅(qū)動(dòng)) 使用操作系統(tǒng)提供的事件通知機(jī)制 (如epoll_wait
,kevent
等) 來(lái)同時(shí)監(jiān)控大量已注冊(cè)的 FD。這些機(jī)制允許一個(gè)線程高效地等待多個(gè) FD 上的事件,而無(wú)需為每個(gè) FD 單獨(dú)創(chuàng)建一個(gè)線程。 - 事件通知 :當(dāng)操作系統(tǒng)內(nèi)核檢測(cè)到某個(gè) FD 上的數(shù)據(jù)已到達(dá)(對(duì)于
read
操作)或可以發(fā)送數(shù)據(jù)(對(duì)于write
操作)時(shí),它會(huì)通知 netpoller。 - G 的喚醒 :Netpoller 收到內(nèi)核通知后,會(huì)識(shí)別出是哪個(gè) G 在等待這個(gè) FD 上的事件。它會(huì)將該 G 從
_Gwaiting
狀態(tài)轉(zhuǎn)換回_Grunnable
狀態(tài),并將其放入一個(gè)運(yùn)行隊(duì)列 (通常是 GRQ,有時(shí)也可能是某個(gè) P 的 LRQ,例如上次運(yùn)行該 G 的 P,以期利用緩存局部性)。 - 調(diào)度執(zhí)行實(shí)際讀操作 :一旦 Gx 變?yōu)?nbsp;
_Grunnable
,它就和其他等待調(diào)度的 G 一樣。當(dāng)某個(gè) M/P 組合選中它執(zhí)行時(shí),它會(huì)從之前中斷的地方恢復(fù)。此時(shí),由于 netpoller 已經(jīng)確認(rèn)數(shù)據(jù)就緒,G 可以執(zhí)行實(shí)際的、現(xiàn)在不會(huì)阻塞的read()
操作來(lái)獲取數(shù)據(jù)。 - 重新調(diào)度執(zhí)行 :一旦 Gx 變?yōu)?nbsp;
_Grunnable
,它就和其他可運(yùn)行的 G 一樣,等待某個(gè) M/P 組合來(lái)執(zhí)行它。當(dāng)輪到它時(shí),它會(huì)從上次阻塞的地方繼續(xù)執(zhí)行。
這種機(jī)制確保了少數(shù) G 的阻塞性 I/O 不會(huì)阻塞整個(gè)程序的并發(fā)執(zhí)行。M 的數(shù)量可能會(huì)根據(jù)需要?jiǎng)討B(tài)調(diào)整(在一定范圍內(nèi)),以適應(yīng)負(fù)載情況。
創(chuàng)建一個(gè) go func(){}()
發(fā)生了什么?
當(dāng)你執(zhí)行一行代碼 go func(){ ... }()
時(shí),Go runtime 會(huì)執(zhí)行以下步驟:
- 創(chuàng)建 G 對(duì)象 :首先,runtime 會(huì)在堆上分配并初始化一個(gè)新的 G 對(duì)象。這個(gè)對(duì)象包含了新 goroutine 的棧信息(初始分配一個(gè)小棧)、程序計(jì)數(shù)器(指向匿名函數(shù)的起始位置)以及其他狀態(tài)信息。
a.設(shè)置初始狀態(tài) :新創(chuàng)建的 G 的初始狀態(tài)被設(shè)置為 _Grunnable
,表示它已經(jīng)準(zhǔn)備好運(yùn)行,只等待調(diào)度器的調(diào)度。
b.放入隊(duì)列 :這個(gè)新的 _Grunnable
的 G 通常會(huì)被嘗試放入當(dāng)前 M 所關(guān)聯(lián)的 P 的 LRQ。
- 如果該 P 的 LRQ 已滿,runtime 會(huì)嘗試將 P 的 LRQ 中的一部分 G(包括這個(gè)新的 G)均衡到 GRQ 中。
- 在某些情況下,如果創(chuàng)建 G 的 P 處于特殊狀態(tài),或者為了更好的負(fù)載均衡,新的 G 也可能直接被放入 GRQ。
- 創(chuàng)建 G 的函數(shù)返回 :
go
語(yǔ)句本身是一個(gè)非阻塞調(diào)用。執(zhí)行go
語(yǔ)句的 goroutine 會(huì)繼續(xù)執(zhí)行其后續(xù)代碼,而不會(huì)等待新創(chuàng)建的 goroutine 開始或完成執(zhí)行。 - 調(diào)度與執(zhí)行 :新創(chuàng)建的 G 現(xiàn)在位于某個(gè)運(yùn)行隊(duì)列中。當(dāng)某個(gè) M(可能就是當(dāng)前的 M,也可能是其他 M)在未來(lái)的某個(gè)調(diào)度點(diǎn)(例如,當(dāng)前 G 執(zhí)行完畢、發(fā)生搶占、或 M 從系統(tǒng)調(diào)用返回時(shí))查找可運(yùn)行的 G 時(shí),它就有機(jī)會(huì)從 LRQ 或 GRQ 中獲取這個(gè)新的 G。獲取到 G 后,M 會(huì)設(shè)置好運(yùn)行環(huán)境(切換到該 G 的棧,設(shè)置 G 的狀態(tài)為
_Grunning
等),然后開始執(zhí)行該匿名函數(shù)內(nèi)的代碼。
整個(gè)過(guò)程與上面描述的 GPM 調(diào)度機(jī)制緊密相連,新的 G 只是作為調(diào)度器可調(diào)度的一個(gè)單元被高效地管理起來(lái)。
調(diào)度策略與搶占機(jī)制
Go 的調(diào)度器采用了一些關(guān)鍵策略來(lái)保證公平性和效率:
- 工作竊取 (Work Stealing) :如前所述,當(dāng)一個(gè) P 的 LRQ 為空時(shí),其關(guān)聯(lián)的 M 會(huì)嘗試從其他 P 的 LRQ 中“竊取”一半的 G 到自己的 LRQ,或者從 GRQ 中獲取 G。這有助于在 P 之間均勻分配工作負(fù)載,防止某些 P 空閑而另一些 P 過(guò)載。
- 搶占 (Preemption) :在 Go 的早期版本中(1.14 之前),搶占主要是協(xié)作式的。也就是說(shuō),一個(gè) goroutine 主動(dòng)放棄 CPU 的執(zhí)行權(quán)通常發(fā)生在函數(shù)調(diào)用時(shí)(編譯器會(huì)在函數(shù)入口處插入檢查點(diǎn),判斷是否需要進(jìn)行棧增長(zhǎng)以及是否需要被搶占)、channel 操作、
select
語(yǔ)句、以及一些同步原語(yǔ)的調(diào)用點(diǎn)。這意味著如果一個(gè) goroutine 執(zhí)行一個(gè)沒(méi)有任何函數(shù)調(diào)用的密集計(jì)算循環(huán) (for {}
),它可能會(huì)長(zhǎng)時(shí)間占據(jù) M,導(dǎo)致同一個(gè) P 上的其他 goroutine 餓死。
從 Go 1.14 版本開始,引入了 基于信號(hào)的異步搶占機(jī)制 (asynchronous preemption) ,以解決上述問(wèn)題:
sysmon
后臺(tái)監(jiān)控線程 :Go runtime 有一個(gè)名為sysmon
的特殊 M(不關(guān)聯(lián) P),它會(huì)定期進(jìn)行一些維護(hù)工作,其中就包括檢查是否有 G 運(yùn)行時(shí)間過(guò)長(zhǎng)(例如,超過(guò)一個(gè)固定的時(shí)間片,通常是 10ms)。- 發(fā)送信號(hào) :如果
sysmon
發(fā)現(xiàn)某個(gè) G 在一個(gè) M 上運(yùn)行時(shí)間過(guò)長(zhǎng),它會(huì)向該 M 發(fā)送一個(gè)搶占信號(hào)(例如,在 Unix 系統(tǒng)上是SIGURG
)。 - 信號(hào)處理 :M 接收到信號(hào)后,會(huì)中斷當(dāng)前正在執(zhí)行的 G。G 的上下文(主要是寄存器)會(huì)被保存,其狀態(tài)會(huì)被標(biāo)記為
_Gpreempted
或類似狀態(tài),然后被放回到運(yùn)行隊(duì)列(通常是 GRQ,以給其他 P 機(jī)會(huì)執(zhí)行它,避免立即在同一個(gè) P 上再次調(diào)度)。 - 重新調(diào)度 :M 隨后會(huì)進(jìn)入調(diào)度循環(huán)(在其
g0
棧上),選擇下一個(gè)可運(yùn)行的 G 來(lái)執(zhí)行。
這種異步搶占機(jī)制確保了即使是那些沒(méi)有主動(dòng)讓出 CPU 的計(jì)算密集型 goroutine 也能夠被公平地調(diào)度,從而提高了整個(gè)系統(tǒng)的響應(yīng)性和并發(fā)任務(wù)的并行度。它使得調(diào)度器更加健壯,不易受到不良編寫的 goroutine 的影響。
func main 也是一個(gè) goroutine
當(dāng)一個(gè) Go 程序啟動(dòng)時(shí),main
包下的 main
函數(shù)并不是直接在某個(gè)原始線程上執(zhí)行,而是由 Go runtime 創(chuàng)建的第一個(gè)用戶級(jí) goroutine,通常被稱為 main goroutine 。
- 初始化過(guò)程 :Go 程序的入口點(diǎn)實(shí)際上是 runtime 的一段引導(dǎo)代碼。這段代碼會(huì)負(fù)責(zé)初始化調(diào)度器、垃圾回收器、創(chuàng)建必要的 M 和 P,然后創(chuàng)建一個(gè) G 來(lái)執(zhí)行用戶編寫的
main.main()
函數(shù)。 - 與其他 goroutine 平等 :這個(gè)
main
goroutine 在行為上與用戶通過(guò)go
關(guān)鍵字創(chuàng)建的其他 goroutine 是平等的。它也擁有自己的棧,受 GPM 調(diào)度器的管理,可以被搶占,也可以創(chuàng)建新的 goroutine。 - 程序生命周期 :
main
goroutine 的結(jié)束標(biāo)志著整個(gè)程序的結(jié)束。當(dāng)main
函數(shù)返回時(shí),Go runtime 會(huì)開始關(guān)閉程序。此時(shí),所有其他仍在運(yùn)行的 goroutine 都會(huì)被強(qiáng)制終止,除非程序使用了像sync.WaitGroup
這樣的機(jī)制來(lái)顯式等待其他 goroutine 完成。 - 退出碼 :
main
函數(shù)沒(méi)有返回值。程序如果正常退出,通常退出碼為 0。如果發(fā)生panic
且未被recover
,或者調(diào)用了os.Exit(code)
,則會(huì)以相應(yīng)的狀態(tài)退出。
理解 main
函數(shù)本身也是一個(gè) goroutine 有助于更好地認(rèn)識(shí) Go 的并發(fā)模型的一致性:所有用戶代碼都運(yùn)行在 goroutine 之上,由統(tǒng)一的 GPM 模型進(jìn)行調(diào)度和管理。這體現(xiàn)了 Go 在語(yǔ)言層面和運(yùn)行時(shí)層面將并發(fā)作為一等公民的設(shè)計(jì)哲學(xué)。