博客 / 詳情

返回

Goroutine 的創建與調度:從 GPM 模型到 go 關鍵字的底層邏輯

併發,對於每個語言來説都是最重要的一部分。Goroutine 採用 m:n 模型,是一種輕量化的多線程處理。


一、為什麼需要 Goroutine ?

在理解 Goroutine 之前,我們先回顧一下的傳統併發模型。在多數編程語言(如 Java、C++)中,併發主要依賴「線程」(Thread)實現。但線程存在兩個問題:

  1. 創建成本高:每個線程需要佔用獨立的棧空間(通常幾 MB),操作系統需要為線程分配內核資源(如寄存器、上下文),創建和銷燬線程的開銷很大。
  2. 調度成本高:線程的調度由操作系統內核控制,涉及用户態與內核態的切換(上下文切換),頻繁切換會嚴重影響性能。

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)):

  1. M 會檢測到 G 進入阻塞狀態(通過系統調用返回的信號)。
  2. M 會解綁當前的 P(將 P 標記為「空閒」),並釋放 P 給其他 M 使用。
  3. M 停止執行當前 G,進入休眠狀態(等待任務喚醒)。
  4. 其他 M 可以搶佔這個空閒的 P,繼續執行其他 G。

場景 2:G 阻塞結束,恢復執行

當 I/O 操作完成(如超時或數據就緒),G 會被喚醒並標記為 runnable

  1. 喚醒後的 G 會被放入某個 P 的本地隊列(通常是原 P,或隨機選擇一個 P)。
  2. 當某個 M 綁定到該 P 時,會從隊列中取出 G 並繼續執行(從阻塞的位置繼續運行)。

場景 3:G 正常結束

當 G 執行完函數邏輯(PC 指向函數返回地址),運行時會回收 G 的棧空間,並將 G 標記為 dead(死亡),最終被垃圾回收器清理。

五、GPM 模型的優勢:為什麼 Go 能高效處理高併發?

GPM 模型通過以下設計,讓 Go 在併發性能上遠超傳統線程模型:

  1. 輕量級 Goroutine:Goroutine 的棧初始僅 2KB,創建成本極低(幾微秒),可以輕鬆創建百萬級 Goroutine。
  2. 用户態調度:調度邏輯由 Go 運行時(而非操作系統內核)實現,避免了內核態與用户態的切換開銷。
  3. 動態負載均衡:工作竊取算法確保所有 P 儘可能忙碌,避免某些 P 閒置。
  4. 減少線程切換: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 併發能力的基石,理解它不僅能幫我們寫出更高效的代碼,還能在遇到併發問題(如死鎖、性能瓶頸)時快速定位根因。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.