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