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 的數量也可以多於操作系統線程一樣。

在深入探討調度器之前, 讓我們先區分一下經常混淆的兩個概念: 併發和並行。

讓我們看看 Go Scheduler 如何使用線程。

PMG 模型

在我們解開內部工作原理之前, 讓我們先解釋一下 P、M 和 G 分別代表什麼意思。

G (goroutine)

goroutine 是 Go 中最小的執行單元, 類似於一個輕量級線程。

在 Go 運行時, 它由一個名爲g的 struct 表示。一旦創建, 它就會被放入邏輯處理器 P 的本地可運行隊列 (或全局隊列), 之後 P 會將它分配給一個實際的內核線程 (M)。

goroutine 通常存在三種主要狀態:

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 是如何協同工作的。

在這裏我不會深入探討每一個細節, 但在後續的文章中會更深入地探討。如果你對此感興趣, 請關注我的公衆號。

  1. 初始化一個 goroutine: 使用 go func() 命令時, Go 運行時會新建一個 goroutine 或從池中選擇一個已存在的 goroutine。

  2. 入隊排位: goroutine 會尋找一個隊列來加入, 如果所有邏輯處理器 (P) 的本地隊列都已滿, 該 goroutine 會被放入全局隊列。

  3. 線程配對: 這就是 M 發揮作用的地方。它獲取一個 P, 並開始從 P 的本地隊列處理 goroutine。當 M 與這個 goroutine 交互時, 其關聯的 P 就會被佔用, 無法分配給其他 M。

  4. 竊取行爲: 如果一個 P 的隊列被耗盡, M 會試圖從另一個 P 的隊列 "借用" 一半可運行的 goroutine。如果失敗, 它會檢查全局隊列, 然後是網絡輪詢器 (參見下面的 "竊取過程" 圖)。

  5. 資源分配: 在 M 選擇一個 goroutine(G) 後, 它會爲運行這個 G 獲取所需的所有資源。

"如果一個線程被阻塞了怎麼辦?"

如果一個 goroutine 啓動了一個需要一段時間的系統調用 (比如讀取文件),M 會一直等待。

但調度器不喜歡一直等待, 它會將被阻塞的 M 從它的 P 上分離, 然後將隊列中另一個可運行的 goroutine 連接到一個新的或已存在的 M 上, M 再與 P 團隊合作。

竊取過程

當一個線程 (M) 完成了它的任務, 沒有其他事情可做時, 它不會就這樣閒置。

相反, 它會主動尋找更多工作, 方法是查看其他處理器並接手它們一半的任務, 讓我們來分解一下這個過程:

  1. 每 61 個嘀嗒, 一個 M 會檢查全局可運行隊列, 以確保公平執行。如果在全局隊列中找到了可運行的 goroutine, 就停止。

  2. 該線程 M 現在會檢查與它所在的處理器 P 相連的本地運行隊列, 看看有沒有可運行的 goroutine 需要處理。

  3. 如果該線程發現它的隊列爲空, 它就會查看全局隊列, 看看是否有任何等待中的任務。

  4. 然後, 該線程會向網絡輪詢器詢問是否有任何與網絡相關的工作。

  5. 如果該線程在檢查完網絡輪詢器後仍然沒有找到任何任務, 它就會進入主動搜索模式, 我們可以將其視爲自旋狀態。

  6. 在這種狀態下, 該線程會嘗試從其他處理器的隊列中 "借用" 任務。

  7. 經過這些步驟後, 如果該線程仍然沒有找到任何工作, 它就會停止主動搜索。

  8. 現在, 如果有新的任務到來, 並且有空閒的處理器沒有正在搜索的線程, 另一個線程就可以開始工作。

需要注意的一點是, 全局隊列實際上被檢查了兩次: 一次是每 61 個嘀嗒檢查一次以保證公平性, 另一次是在本地隊列爲空時檢查。

"如果 M 已與其 P 綁定, 它怎麼能從其他處理器獲取任務呢? M 會改變它的 P 嗎?"

答案是不會。

即使 M 從另一個 P 的隊列中獲取任務, 它也是使用原來的 P 來運行該任務。因此, 儘管 M 獲取了新任務, 但它仍然忠於自己的 P。

"爲什麼是 61?"

在設計算法時, 尤其是哈希算法時, 通常會選擇素數, 因爲素數除了 1 和自身之外沒有其他因子。

這可以減少出現模式或規律性的可能性, 從而避免發生 "衝突" 或其他不希望出現的行爲。

如果時間過短, 系統可能會頻繁浪費資源檢查全局運行隊列。如果時間過長, goroutine 可能會在執行前過度等待。

網絡輪詢器 (Network Poller)

我們還沒有太多討論這個網絡輪詢器, 但它出現在了竊取過程的示意圖中。

與 Go 調度器一樣, 網絡輪詢器也是 Go 運行時的一個組件, 負責處理與網絡相關的調用 (例如網絡 I/O)。

讓我們比較一下兩種系統調用類型:

在後續部分, 我們將更深入地探討搶佔式調度, 並分析調度器在運行過程中所採取的每一步驟。

原文: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