Go 的 GMP 調度模型,看這篇就足夠了

意志命運往往背道而馳,決心到最後會全部推倒。——莎士比亞

Goroutine 調度是一個很複雜的機制,儘管 Go 源碼中提供了大量的註釋,但對其原理沒有一個好的理解的情況下去讀源碼收穫不會很大。下面嘗試用簡單的語言描述一下 Goroutine 調度機制,在此基礎上再去研讀源碼效果可能更好一些。

1 線程池的缺陷

我們知道,在高併發應用中頻繁創建線程會造成不必要的開銷,所以有了線程池。線程池中預先保存一定數量的線程,而新任務將不再以創建線程的方式去執行,而是將任務發佈到任務隊列,線程池中的線程不斷的從任務隊列中取出任務並執行,可以有效的減少線程創建和銷燬所帶來的開銷。

下圖展示一個典型的線程池:

爲了方便下面的敘述,我們把任務隊列中的每一個任務稱作 G,而 G 往往代表一個函數。線程池中的線程 worker 線程不斷的從任務隊列中取出任務並執行。而 worker 線程的調度則交給操作系統進行調度。

如果 worker 線程執行的 G 任務中發生系統調用,則操作系統會將該線程置爲阻塞狀態,也意味着該線程在怠工,也意味着消費任務隊列的 worker 線程變少了,也就是說線程池消費任務隊列的能力變弱了。

如果任務隊列中的大部分任務都會進行系統調用,則會讓這種狀態惡化,大部分 worker 線程進入阻塞狀態,從而任務隊列中的任務產生堆積。

解決這個問題的一個思路就是重新審視線程池中線程的數量,增加線程池中線程數量可以一定程度上提高消費能力,但隨着線程數量增多,由於過多線程爭搶 CPU,消費能力會有上限,甚至出現消費能力下降。如下圖所示:

2 Goroutine 調度器 - GMP

線程數過多,意味着操作系統會不斷的切換線程,頻繁的上下文切換就成了性能瓶頸。Go 提供一種機制,可以在線程中自己實現調度,上下文切換更輕量,從而達到了線程數少,而併發數並不少的效果。而線程中調度的就是 Goroutine.

早期 Go 版本,比如 1.9.2 版本的源碼註釋中有關於調度器的解釋。Goroutine 調度器的工作就是把 “ready-to-run” 的 goroutine 分發到線程中。

Goroutine 主要概念如下:

M 必須擁有 P 纔可以執行 G 中的代碼,P 含有一個包含多個 G 的隊列,P 可以調度 G 交由 M 執行。其關係如下圖所示:

圖中 M 是交給操作系統調度的線程,M 持有一個 P,P 將 G 調度進 M 中執行。P 同時還維護着一個包含 G 的隊列(圖中灰色部分),可以按照一定的策略將不能的 G 調度進 M 中執行。

P 的個數在程序啓動時決定,默認情況下等同於 CPU 的核數,由於 M 必須持有一個 P 纔可以運行 Go 代碼,所以同時運行的 M 個數,也即線程數一般等同於 CPU 的個數,以達到儘可能的使用 CPU 而又不至於產生過多的線程切換開銷。

程序中可以使用 runtime.GOMAXPROCS() 設置 P 的個數,在某些 IO 密集型的場景下可以在一定程度上提高性能。這個後面再詳細介紹。

3 Goroutine 調度策略

3.1 隊列輪轉

上圖中可見每個 P 維護着一個包含 G 的隊列,不考慮 G 進入系統調用或 IO 操作的情況下,P 週期性的將 G 調度到 M 中執行,執行一小段時間,將上下文保存下來,然後將 G 放到隊列尾部,然後從隊列中重新取出一個 G 進行調度。

除了每個 P 維護的 G 隊列以外,還有一個全局的隊列,每個 P 會週期性的查看全局隊列中是否有 G 待運行並將期調度到 M 中執行,全局隊列中 G 的來源,主要有從系統調用中恢復的 G。之所以 P 會週期性的查看全局隊列,也是爲了防止全局隊列中的 G 被餓死。

3.2 系統調用

上面說到 P 的個數默認等於 CPU 核數,每個 M 必須持有一個 P 纔可以執行 G,一般情況下 M 的個數會略大於 P 的個數,這多出來的 M 將會在 G 產生系統調用時發揮作用。類似線程池,Go 也提供一個 M 的池子,需要時從池子中獲取,用完放回池子,不夠用時就再創建一個。

當 M 運行的某個 G 產生系統調用時,如下圖所示:

如圖所示,當 G0 即將進入系統調用時,M0 將釋放 P,進而某個空閒的 M1 獲取 P,繼續執行 P 隊列中剩下的 G。而 M0 由於陷入系統調用而進被阻塞,M1 接替 M0 的工作,只要 P 不空閒,就可以保證充分利用 CPU。

M1 的來源有可能是 M 的緩存池,也可能是新建的。當 G0 系統調用結束後,跟據 M0 是否能獲取到 P,將會將 G0 做不同的處理:

  1. 如果有空閒的 P,則獲取一個 P,繼續執行 G0。

  2. 如果沒有空閒的 P,則將 G0 放入全局隊列,等待被其他的 P 調度。然後 M0 將進入緩存池睡眠。

3.3 工作量竊取

多個 P 中維護的 G 隊列有可能是不均衡的,比如下圖: 

豎線左側中右邊的 P 已經將 G 全部執行完,然後去查詢全局隊列,全局隊列中也沒有 G,而另一個 M 中除了正在運行的 G 外,隊列中還有 3 個 G 待運行。此時,空閒的 P 會將其他 P 中的 G 偷取一部分過來,一般每次偷取一半。偷取完如右圖所示。

4 GOMAXPROCS 設置對性能的影響

一般來講,程序運行時就將 GOMAXPROCS 大小設置爲 CPU 核數,可讓 Go 程序充分利用 CPU。在某些 IO 密集型的應用裏,這個值可能並不意味着性能最好。理論上當某個 Goroutine 進入系統調用時,會有一個新的 M 被啓用或創建,繼續佔滿 CPU。但由於 Go 調度器檢測到 M 被阻塞是有一定延遲的,也即舊的 M 被阻塞和新的 M 得到運行之間是有一定間隔的,所以在 IO 密集型應用中不妨把 GOMAXPROCS 設置的大一些,或許會有好的效果。

5 關注公衆號

微信公衆號:堆棧 future

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