Goroutine 調度器揭祕
你以前可能聽說過 Goroutine 調度器,但你對它的工作原理了解多少?它如何將 goroutine 與線程配對?
不用着急理解上面的圖像, 因爲我們要從最基本的開始。
goroutine 被分配到線程中運行, 這由 goroutine 調度器在後臺處理。根據我們之前的討論, 我們瞭解到以下關於 goroutine 的幾點:
-
就原始執行速度而言, goroutine 並不一定比線程更快, 因爲它們需要在實際線程上運行。
-
goroutine 真正的優勢在於上下文切換、內存佔用、創建和銷燬的成本等方面。
你可能以前聽說過 goroutine 調度器, 但我們真正瞭解它的工作原理嗎? 它是如何將 goroutine 與線程配對的?
現在讓我們一步一步地分解調度器的工作原理。
goroutine M:N 調度器模型
Go 團隊真的爲我們簡化了併發編程, 想想看: 創建一個 goroutine 只需要在函數前加上 go 關鍵字就可以了。
go doWork()
但在這個簡單的步驟背後, 有一個更深層次的系統在運作。
一開始, Go 就沒有簡單地爲我們提供線程。相反, 中間有一個助手, 即 goroutine 調度器, 它是 Go 運行時的關鍵部分。
那麼M:N
這個標籤是什麼意思呢?
它體現了 Go 調度器在將M
個 goroutine 映射到N
個內核線程方面的作用, 形成了M:N
模型。操作系統線程的數量可以多於 CPU 核心數, 就像 goroutine 的數量也可以多於操作系統線程一樣。
在深入探討調度器之前, 讓我們先區分一下經常混淆的兩個概念: 併發和並行。
-
併發: 指同時處理多個任務, 這些任務都在運行, 但不一定在同一時間運行。
-
並行: 指多個任務在同一時刻真正同時運行, 通常使用多個 CPU 核心。
-
讓我們看看 Go Scheduler 如何使用線程。
PMG 模型
在我們解開內部工作原理之前, 讓我們先解釋一下 P、M 和 G 分別代表什麼意思。
G (goroutine)
goroutine 是 Go 中最小的執行單元, 類似於一個輕量級線程。
在 Go 運行時, 它由一個名爲g
的 struct 表示。一旦創建, 它就會被放入邏輯處理器 P 的本地可運行隊列 (或全局隊列), 之後 P 會將它分配給一個實際的內核線程 (M)。
goroutine 通常存在三種主要狀態:
-
Waiting: 在這個階段, goroutine 處於靜止狀態, 可能是由於等待某個操作 (如 channel 或鎖), 或者是被系統調用阻塞。
-
Runnable:goroutine 已準備就緒, 但尚未開始運行, 它正在等待輪到在線程 (M) 上運行。
-
Running: 現在 goroutine 正在線程 (M) 上積極執行。它將一直運行直到任務完成, 除非調度器中斷它或其他事物阻礙了它的運行。
goroutine 不是一次性使用後就被丟棄的。
相反, 當啓動一個新的 goroutine 時, Go 的運行時會從 goroutine 池中選擇一個, 如果池中沒有, 它會創建一個新的。然後, 這個新的 goroutine 會加入某個 P 的可運行隊列。
P(邏輯處理器)
在 Go 調度器中, 當我們提到 "處理器" 時, 指的是一個邏輯實體, 而不是物理實體。
默認情況下, P 的數量設置爲可用的 CPU 核心數, 你可以使用 runtime.GOMAXPROCS(int) 檢查或更改這些處理器的數量:
runtime.GOMAXPROCS(0) // get the current allowed number of logical processors
// Output: 8 (depends on your machine)
如果你想修改 P 的數量, 最好在應用程序啓動時就這樣做, 因爲如果在運行時修改, 它會導致STW
(stopTheWorld), 所有操作都會暫停, 直到處理器大小調整完成。
每個 P 都有自己的可運行 goroutine 列表, 稱爲本地運行隊列 (Local Run Queue), 最多可容納 256 個 goroutine。
如果 P 的隊列已滿 (256 個 goroutine), 還有一個名爲全局運行隊列(Global Run Queue) 的共享隊列, 不過我們稍後再討論這個。
❝
"那麼,'P'的數量真正顯示了什麼呢?"
它表示可以併發運行的 goroutine 數量 - 想象它們並排運行。
M(機器線程 - 操作系統線程)
一個典型的 Go 程序最多可使用 10,000 個線程。
沒錯, 我說的是線程而不是 goroutine。如果超過這個限制, 你的 Go 應用程序就有崩潰的風險。
❝
"線程是何時創建的呢?"
想象這種情況: 一個 goroutine 處於可運行狀態並需要一個線程。
如果所有線程都已被阻塞, 可能是由於系統調用或不可搶佔的操作, 會發生什麼? 在這種情況下, 調度器會介入併爲該 goroutine 創建一個新線程。
(需要注意的一點是: 如果一個線程只是在進行昂貴的計算或長時間運行的任務, 它不被視爲陷入困境或被阻塞)
如果你想改變默認的線程限制, 可以使用runtime/debug.SetMaxThreads()
函數, 它允許你設置 Go 程序可使用的最大操作系統線程數。
另外, 值得一提的是, 線程會被重用, 因爲創建或刪除線程是一個資源密集型的操作。
MPG 是如何協同工作的
讓我們通過以下步驟一步步理解 M、P 和 G 是如何協同工作的。
在這裏我不會深入探討每一個細節, 但在後續的文章中會更深入地探討。如果你對此感興趣, 請關注我的公衆號。
-
初始化一個 goroutine: 使用 go func() 命令時, Go 運行時會新建一個 goroutine 或從池中選擇一個已存在的 goroutine。
-
入隊排位: goroutine 會尋找一個隊列來加入, 如果所有邏輯處理器 (P) 的本地隊列都已滿, 該 goroutine 會被放入全局隊列。
-
線程配對: 這就是 M 發揮作用的地方。它獲取一個 P, 並開始從 P 的本地隊列處理 goroutine。當 M 與這個 goroutine 交互時, 其關聯的 P 就會被佔用, 無法分配給其他 M。
-
竊取行爲: 如果一個 P 的隊列被耗盡, M 會試圖從另一個 P 的隊列 "借用" 一半可運行的 goroutine。如果失敗, 它會檢查全局隊列, 然後是網絡輪詢器 (參見下面的 "竊取過程" 圖)。
-
資源分配: 在 M 選擇一個 goroutine(G) 後, 它會爲運行這個 G 獲取所需的所有資源。
❝
"如果一個線程被阻塞了怎麼辦?"
如果一個 goroutine 啓動了一個需要一段時間的系統調用 (比如讀取文件),M 會一直等待。
但調度器不喜歡一直等待, 它會將被阻塞的 M 從它的 P 上分離, 然後將隊列中另一個可運行的 goroutine 連接到一個新的或已存在的 M 上, M 再與 P 團隊合作。
竊取過程
當一個線程 (M) 完成了它的任務, 沒有其他事情可做時, 它不會就這樣閒置。
相反, 它會主動尋找更多工作, 方法是查看其他處理器並接手它們一半的任務, 讓我們來分解一下這個過程:
-
每 61 個嘀嗒, 一個 M 會檢查全局可運行隊列, 以確保公平執行。如果在全局隊列中找到了可運行的 goroutine, 就停止。
-
該線程 M 現在會檢查與它所在的處理器 P 相連的本地運行隊列, 看看有沒有可運行的 goroutine 需要處理。
-
如果該線程發現它的隊列爲空, 它就會查看全局隊列, 看看是否有任何等待中的任務。
-
然後, 該線程會向網絡輪詢器詢問是否有任何與網絡相關的工作。
-
如果該線程在檢查完網絡輪詢器後仍然沒有找到任何任務, 它就會進入主動搜索模式, 我們可以將其視爲自旋狀態。
-
在這種狀態下, 該線程會嘗試從其他處理器的隊列中 "借用" 任務。
-
經過這些步驟後, 如果該線程仍然沒有找到任何工作, 它就會停止主動搜索。
-
現在, 如果有新的任務到來, 並且有空閒的處理器沒有正在搜索的線程, 另一個線程就可以開始工作。
需要注意的一點是, 全局隊列實際上被檢查了兩次: 一次是每 61 個嘀嗒檢查一次以保證公平性, 另一次是在本地隊列爲空時檢查。
❝
"如果 M 已與其 P 綁定, 它怎麼能從其他處理器獲取任務呢? M 會改變它的 P 嗎?"
答案是不會。
即使 M 從另一個 P 的隊列中獲取任務, 它也是使用原來的 P 來運行該任務。因此, 儘管 M 獲取了新任務, 但它仍然忠於自己的 P。
❝
"爲什麼是 61?"
在設計算法時, 尤其是哈希算法時, 通常會選擇素數, 因爲素數除了 1 和自身之外沒有其他因子。
這可以減少出現模式或規律性的可能性, 從而避免發生 "衝突" 或其他不希望出現的行爲。
如果時間過短, 系統可能會頻繁浪費資源檢查全局運行隊列。如果時間過長, goroutine 可能會在執行前過度等待。
網絡輪詢器 (Network Poller)
我們還沒有太多討論這個網絡輪詢器, 但它出現在了竊取過程的示意圖中。
與 Go 調度器一樣, 網絡輪詢器也是 Go 運行時的一個組件, 負責處理與網絡相關的調用 (例如網絡 I/O)。
讓我們比較一下兩種系統調用類型:
-
與網絡相關的系統調用: 當一個 goroutine 執行網絡 I/O 操作時, 它不會阻塞當前線程, 而是向網絡輪詢器註冊。輪詢器異步等待操作完成, 一旦完成, 該 goroutine 就可以再次變爲可運行狀態, 並在某個線程上繼續執行。
-
其他系統調用: 如果它們可能會阻塞且沒有由網絡輪詢器處理, 它們可能會導致 goroutine 將執行卸載到一個操作系統線程上。只有該特定的操作系統線程會被阻塞, Go 運行時調度器可以在其他線程上執行其他 goroutine。
在後續部分, 我們將更深入地探討搶佔式調度, 並分析調度器在運行過程中所採取的每一步驟。
原文:Goroutine Scheduler Revealed: Never See Goroutines the Same Way Again[1]
Reference
[1]
Goroutine Scheduler Revealed: Never See Goroutines the Same Way Again: https://blog.devtrovert.com/p/goroutine-scheduler-revealed-youll
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/mAUmnDxzwntwW4uA2i2q3g