揭祕系列: Goroutine 調度器

現在不要擔心理解上面的圖片,因爲我們將從非常基礎的知識開始。

Goroutines 分佈在線程中,由 Goroutine 調度器在幕後處理。根據我們之前的討論,我們知道一些關於 Goroutines 的事情:

• 從原始執行速度來看,Goroutines 不一定比線程更快,因爲它們需要一個實際的線程來運行。•Goroutines 的真正優勢在於上下文切換、內存佔用、創建和拆除的成本等方面。

你可能之前聽說過 Goroutine 調度器,但我們真正瞭解它是如何工作的嗎?它是如何將 Goroutines 與線程配對的?

現在讓我們一步一步地分解調度器的操作。

1. Goroutine 的 M:N 調度器

Go 團隊爲我們真正簡化了併發處理,想想看:創建一個 Goroutine 就像在函數前面加上 go 關鍵字一樣容易。

go doWork()

但在這個簡單的步驟背後,有一個更深層的系統在運作。

從一開始,Go 並不是簡單地提供了線程。相反,在中間有一個輔助工具,Goroutine 調度器,它是 Go 運行時的關鍵部分。

那麼什麼是 M:N 標籤?

它表示 Go 調度器在將 M 個 Goroutines 映射到 N 個內核線程時所起的作用,形成了 M:N 模型。你可以擁有更多的操作系統線程,就像可以擁有更多的 Goroutines 一樣。

在我們深入研究調度器之前,讓我們澄清一下經常混淆的兩個術語:併發和並行。

併發:這是關於同時處理多個任務,它們都在運動,但不總是在同一時刻。• 並行:這意味着許多任務在完全相同的時間運行,通常使用多個 CPU 核心。

讓我們看看 Go 調度器是如何與線程配合運作的。

2. PMG 模型

在我們深入研究內部工作原理之前,讓我們分解一下 P、M 和 G 代表什麼。

G(Goroutine)

Goroutine 充當 Go 的最小執行單元,類似於輕量級線程。

在 Go 的運行時中,它由一個名爲gstruct{}表示。一旦創建,它會找到一個邏輯處理器 P 的本地可運行隊列(或 G 隊列)中的位置,然後 P 將其交給一個實際的內核線程 M。

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

等待:在這個階段,Goroutine 停滯不前,可能因爲通道或鎖之類的操作而暫停,或者可能被系統調用暫停。

可運行:Goroutine 已經準備好運行,但尚未開始運行,它正在等待輪到它在一個線程(M)上運行。

運行:現在,Goroutine 正在一個線程(M)上積極執行。它會一直執行,直到任務完成,除非調度器中斷它或其他原因阻止了它的執行。

Goroutines 並不僅僅被使用一次然後被丟棄。

相反,當啓動新的 Goroutine 時,Go 的運行時會從 Goroutine 池

中選擇一個,但如果找不到任何可用的 Goroutine,它會創建一個新的。然後,這個新的 Goroutine 加入到一個 P 的可運行隊列中。

P(邏輯處理器)

在 Go 調度器中,當我們提到 “處理器” 時,我們指的是邏輯實體,而不是物理實體。

默認情況下,P 的數量設置爲可用的核心數,你可以使用runtime.GOMAXPROCS(int)函數來檢查或更改這些處理器的數量。

runtime.GOMAXPROCS(0) // 獲取當前允許的邏輯處理器數量

如果你想更改這個數量,最好是在應用程序啓動時更改它,如果在運行時更改,它會導致 STW(停止一切),直到重新調整處理器。

每個 P 都擁有自己的可運行 Goroutines 列表,稱爲本地運行隊列,最多可以容納 256 個 Goroutines。

調度器 — P(邏輯處理器)

如果 P 的隊列達到了最大 Coroutines 數(256),那麼就有一個共享隊列,稱爲全局運行隊列,但我們將稍後討論這個。

"那麼'P'的這個數量到底代表什麼?" 它表示可以同時運行的 Goroutines 數量 — 想象它們並行運行。

M(機器線程 — 操作系統線程)

一個典型的 Go 程序最多可以使用 1 萬個線程。

是的,我說的是線程,而不是 Goroutines。如果超出這個限制,你可能會使你的 Go 應用程序崩潰。

"什麼情況下會創建一個線程?" 想象一種情況:一個 Goroutine 處於可運行狀態並需要一個線程。如果所有線程已經被阻塞,可能是因爲系統調用或非搶佔操作,會怎麼樣?在這種情況下,調度器會介入併爲該 Goroutine 創建一個新線程。一個需要注意的事情是,如果一個線程只是忙於昂貴的計算或長時間運行的任務,它不被視爲被卡住或被阻塞。如果你想更改默認線程限制,你可以使用 runtime/debug.SetMaxThreads() 函數,它允許你設置你的 Go 程序可以使用的操作系統線程的最大數量。此外,值得知道的是,線程是可以重複使用的,因爲創建或銷燬線程是消耗資源的。

3. MPG 工作原理

讓我們通過項目符號逐步瞭解 M、P 和 G 如何一起運作。

我不會在這裏深入討論每個細節,但我將在即將發佈的故事中深入探討。如果你感興趣,請訂閱。

Go Scheduler 的工作原理

  1. 初始化 goroutine:通過使用 go func() 命令,Go Runtime 要麼創建一個新的 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 連接起來,然後與 P 協作。

被阻塞的線程

竊取過程

當一個線程(M)完成了它的任務並沒有其他事情可做時,它不會坐在那裏。

相反,它積極地尋找更多工作,觀察其他處理器並獲取它們一半的任務,讓我們來詳細瞭解一下:

  1. 每 61 個時鐘滴答,M 檢查全局可運行隊列,以確保執行的公平性。如果在全局隊列中找到一個可運行的 goroutine,就停止。

  2. 然後,線程 M 檢查其本地運行隊列,與其處理器 P 相關聯,以查看是否有可運行的 goroutine 可以處理。

  3. 如果線程發現它的隊列是空的,那麼它會查看全局隊列,看看那裏是否有等待處理的任務。

  4. 然後,線程會檢查網絡輪詢器,以查看是否有與網絡相關的任務。

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

  6. 在這種狀態下,線程試圖從其他處理器的隊列中 “借用” 任務。

  7. 經過所有這些步驟後,如果線程仍然找不到工作,它將停止主動搜索。

  8. 現在,如果有新的任務進來,而且有一個沒有在搜索狀態的空閒處理器,那麼可以提示另一個線程開始工作。

需要注意的細節是全局隊列實際上被檢查了兩次:每 61 個時鐘滴答一次以確保公平性,如果本地隊列爲空,就再次檢查。

“如果 M 與其 P 相關聯,它怎麼能從其他處理器那裏獲取任務呢?M 會更改其 P 嗎?” 答案是不會。即使 M 從另一個 P 的隊列中獲取任務,它仍然使用其原始處理器來運行該任務。因此,在 M 承擔新任務的同時,它仍然忠實於其處理器。

“爲什麼是 61?” 在設計算法時,特別是哈希算法,通常會選擇質數,因爲它們除了 1 和它們自己之外沒有除數。這可以降低出現模式或規律的機會,從而防止 “碰撞” 或其他不希望出現的行爲。如果太短,系統可能會浪費資源頻繁檢查全局運行隊列。如果太長,goroutine 可能會在執行之前等待過長的時間。

網絡輪詢器

我們還沒有詳細討論網絡輪詢器,但它在竊取過程圖表中提到了。

與 Go Scheduler 一樣,網絡輪詢器是 Go Runtime 的組成部分,負責處理與網絡相關的調用(例如,網絡 I/O)。

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

• 與網絡相關的系統調用:當一個 goroutine 執行網絡 I/O 操作時,它不會阻塞線程,而是會在網絡輪詢器中註冊。輪詢器會異步等待操作完成,一旦完成,goroutine 就會再次可運行,可以在一個線程上繼續執行。

• 其他系統調用:如果它們可能會阻塞並且不由網絡輪詢器處理,它們可能會導致 goroutine 將其執行卸載到操作系統線程上。只有特定的操作系統線程會被阻塞,Go 運行時調度程序可以在不同線程上執行其他 goroutine。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Apk4LWJf5TVvDgbdoG707w