併發,對於每個語言來説都是最重要的一部分。Goroutine 採用 m:n 模型,是一種輕量化的多線程處理。
一、為什麼需要 Goroutine ?
在理解 Goroutine 之前,我們先回顧一下的傳統併發模型。在多數編程語言(如 Java、C++)中,併發主要依賴「線程」(Thread)實現。但線程存在兩個問題:
- 創建成本高:每個線程需要佔用獨立的棧空間(通常幾 MB),操作系統需要為線程分配內核資源(如寄存器、上下文),創建和銷燬線程的開銷很大。
- 調度成本高:線程的調度由操作系統內核控制,涉及用户態與內核態的切換(上下文切換),頻繁切換會嚴重影響性能。
Go 語言的 Goroutine 就是為了解決這些問題而生的。它是一種 用户態的輕量級線程,由 Go 運行時(Runtime)管理,而不是操作系統內核。Goroutine 的棧初始只有 2KB(動態擴縮),創建成本極低(幾微秒),且 Go 運行時會通過 GPM 調度模型 高效調度大量 Goroutine 到少量操作系統線程(M)上執行,輕鬆實現「百萬級併發」。
當然,並非這種輕量級線程在 Go 語言中獨有,而是 Go 語言原生就是這樣處理的。譬如最近挺火的 Rust 語言,它採用的也是系統級線程處理併發。但是社區也有類似 GMP 的 m:n 模型的內部調度的優秀第三方庫,實現類似的輕量級線程。這裏也可以看出 Golang 官方的設計思想及其場景定位。
二、GPM 模型:Goroutine 調度的核心引擎
GPM 是 Go 調度器的核心模型,由三個關鍵組件構成:G(Goroutine)、P(Processor)、M(Machine)。理解這三個組件的角色和協作關係,是掌握 Goroutine 調度的關鍵。
1. G:Goroutine 的「實體」
G(Goroutine)是用户級線程的抽象,代表一個待執行的 Goroutine。每個 G 包含以下核心信息:
- 棧(Stack):存儲 Goroutine 的局部變量、調用棧等(初始 2KB,動態擴縮)。
- 狀態(State):Goroutine 的當前狀態(如
runnable可運行、running運行中、blocked阻塞等)。 - PC(程序計數器):記錄當前執行的指令位置。
- G 函數:Goroutine 啓動時要執行的函數(如
go func() {}中的匿名函數)。
2. P:Goroutine 的「執行上下文」
P(Processor)是「邏輯處理器」,負責橋接 G 和 M。它不直接參與計算,而是為 Goroutine 提供 執行所需的上下文環境(如本地 Goroutine 隊列、內存分配緩存等)。每個 P 包含:
- 本地 Goroutine 隊列(Local Run Queue, LRQ):存儲待執行的 G(最多 256 個)。
- 調度相關的緩存:如內存分配的臨時對象緩存(減少系統調用)。
- 指向 M 的指針:P 必須綁定一個 M 才能讓 G 運行(P 與 M 是「一對一」的關係)。
P 的數量由 GOMAXPROCS 環境變量或 runtime.GOMAXPROCS() 函數控制(默認等於 CPU 核心數),它決定了 Go 程序能同時利用的 CPU 核心數,直接影響並行度。
3. M:操作系統線程的「代理」
M(Machine)是操作系統線程(OS Thread)的抽象,真正負責在 CPU 上執行指令。每個 M 必須綁定一個 P 才能工作(M 與 P 是「多對一」的關係,一個 P 可以被多個 M 競爭,但同一時間只有一個 M 能使用 P)。M 的核心職責是:
- 執行 G 的代碼:從 P 的本地隊列或全局隊列中取出 G,切換到 G 的棧,執行 G 函數。
- 處理阻塞與喚醒:當 G 執行系統調用(如 I/O)阻塞時,M 會釋放 P,讓其他 M 接管 P 繼續執行其他 G;當阻塞結束,G 會被重新放入某個 P 的隊列等待執行。
GPM 協作關係圖
用一張圖總結三者的關係:
+-------------------+ +-------------------+ +-------------------+
| G (Goroutine) | --> | P (Processor) | --> | M (Machine) |
| (待執行的任務) | | (執行上下文) | | (OS 線程) |
+-------------------+ +-------------------+ +-------------------+
↑ ↑
| (被放入隊列) | (綁定並驅動)
| |
+-------------------+ +-------------------+
| 全局 G 隊列 | | 其他 P 的本地隊列 |
| (備用任務池) | | (負載均衡來源) |
+-------------------+ +-------------------+
三、go 關鍵字的底層觸發流程:Goroutine 是如何被「創建」的?
現在我們知道了 GPM 模型的組件,接下來看 go 關鍵字觸發 Goroutine 創建的完整流程。假設我們有一段代碼:
func main() {
go func() {
fmt.Println("Hello, Goroutine!")
}()
}
當執行 go func() 時,Go 運行時會按以下步驟創建並調度 Goroutine:
步驟 1:創建 G 對象
運行時首先在堆或棧上分配一個 g 結構體(Goroutine 的實體),並初始化其關鍵字段:
- 棧:初始分配 2KB 的棧空間(動態擴縮)。
- PC:指向
func()函數的入口地址(即 Goroutine 要執行的第一個指令)。 - 狀態:標記為
runnable(可運行)。
步驟 2:將 G 放入 P 的本地隊列或全局隊列
創建 G 後,運行時會嘗試將 G 放入當前 P 的 本地 Goroutine 隊列(LRQ) 中。如果本地隊列已滿(最多 256 個),則將 G 放入 全局 Goroutine 隊列(GRQ) 中(全局隊列是所有 P 共享的任務池)。
步驟 3:喚醒或創建 M 來執行 G
P 必須綁定一個 M 才能讓 G 運行。此時有兩種情況:
- 已有綁定的 M:如果 P 已經綁定了一個空閒的 M(正在運行其他 G 或等待任務),M 會立即從 P 的本地隊列中取出 G 並執行。
- 無空閒的 M:如果 P 沒有綁定的 M(或 M 正在執行其他任務),運行時會創建一個新的 M(或喚醒一個休眠的 M),並將 M 綁定到 P 上。M 啓動後,會從 P 的本地隊列中取出 G 開始執行。
關鍵細節:調度器的「偷任務」機制
為了實現負載均衡,Go 調度器實現了 工作竊取(Work Stealing) 算法:
- 當一個 P 的本地隊列為空時,它會嘗試從其他 P 的本地隊列「偷」一半的 G(比如從 P2 偷 128 個 G 到自己的隊列)。
- 如果所有 P 的本地隊列都為空,M 會從全局隊列中獲取 G(全局隊列的任務會被均勻分配到各個 P)。
四、Goroutine 的調度:阻塞、恢復、結束
Goroutine 的生命週期中,最關鍵的是處理 阻塞與恢復。例如,當 G 執行 I/O 操作(如讀取文件、網絡請求)時,會阻塞當前 M,此時調度器會如何處理?
場景 1:G 阻塞(如系統調用)
假設 G 正在執行一個耗時的 I/O 操作(如 time.Sleep(1000 * time.Millisecond)):
- M 會檢測到 G 進入阻塞狀態(通過系統調用返回的信號)。
- M 會解綁當前的 P(將 P 標記為「空閒」),並釋放 P 給其他 M 使用。
- M 停止執行當前 G,進入休眠狀態(等待任務喚醒)。
- 其他 M 可以搶佔這個空閒的 P,繼續執行其他 G。
場景 2:G 阻塞結束,恢復執行
當 I/O 操作完成(如超時或數據就緒),G 會被喚醒並標記為 runnable:
- 喚醒後的 G 會被放入某個 P 的本地隊列(通常是原 P,或隨機選擇一個 P)。
- 當某個 M 綁定到該 P 時,會從隊列中取出 G 並繼續執行(從阻塞的位置繼續運行)。
場景 3:G 正常結束
當 G 執行完函數邏輯(PC 指向函數返回地址),運行時會回收 G 的棧空間,並將 G 標記為 dead(死亡),最終被垃圾回收器清理。
五、GPM 模型的優勢:為什麼 Go 能高效處理高併發?
GPM 模型通過以下設計,讓 Go 在併發性能上遠超傳統線程模型:
- 輕量級 Goroutine:Goroutine 的棧初始僅 2KB,創建成本極低(幾微秒),可以輕鬆創建百萬級 Goroutine。
- 用户態調度:調度邏輯由 Go 運行時(而非操作系統內核)實現,避免了內核態與用户態的切換開銷。
- 動態負載均衡:工作竊取算法確保所有 P 儘可能忙碌,避免某些 P 閒置。
- 減少線程切換:M 的數量遠小於 Goroutine 數量(通常等於 CPU 核心數),大幅減少了線程上下文切換的開銷。
六、總結:理解 GPM 對開發的啓發
通過 GPM 模型和 go 關鍵字的底層流程,我們可以得出一些對實際開發的啓發:
- 避免阻塞 P:如果一個 Goroutine 長時間阻塞(如死循環不釋放 CPU),會導致綁定的 P 無法執行其他 G,降低併發效率。此時應使用
runtime.Gosched()主動讓出 CPU。 - 合理控制 Goroutine 數量:雖然 Goroutine 輕量,但無限制創建仍會消耗內存(每個 G 至少需要幾 KB 棧)和調度資源。
- 利用
GOMAXPROCS調整並行度:對於 CPU 密集型任務,GOMAXPROCS應等於 CPU 核心數;對於 I/O 密集型任務,可以適當調大(但需測試驗證)。
GPM 模型是 Go 併發能力的基石,理解它不僅能幫我們寫出更高效的代碼,還能在遇到併發問題(如死鎖、性能瓶頸)時快速定位根因。