Go 高階指南 - 協程的深入剖析

線程池的缺陷

在高併發中,如果去頻繁的創建線程會產生不必要的開銷,所以有了線程池,它可以預先保存一定數量的線程,新的任務不必再去創建線程,而是將任務發佈到任務隊列,線程池中的線程不斷的從任務隊列中取出任務並執行,這樣可以有效的減少線程創建和銷燬所帶來的開銷。

如上圖,我們把任務隊列中的每個任務稱爲 G ,G 往往代表一個函數。線程池中的 worker 線程不斷的從任務隊列中取出任務執行,worker 線程的調度是由操作系統來進行調度的。

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

如果任務隊列中的大部分任務都進行系統調用,大部分 worker 線程進入阻塞狀態,導致任務隊列中的任務產生堆積。

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

Goroutine 調度器

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

Goroutine 主要概念如下:

G(Goroutine): 即 Go 協程,每個 go 關鍵字都會創建一個協程。M(Machine):工作線程,在 Go 中稱爲 Machine。P(Processor): 處理器(Go 中定義的一個概念,不是指 CPU),包含運行 Go 代碼的必要資源,也有調度 goroutine 的能力。

其關係如下圖所示:

如上圖:

Goroutine 調度策略

隊列輪轉

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

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

系統調用

前面說到 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,則獲取一個繼續執行 G0。

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

工作量竊取

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

另一種情況是 P 所分配的任務 G 很快就執行完了(分配不均),這就導致了這個處理器 P 很忙,但是其他的 P 還有任務,此時如果 global runqueue 沒有任務 G 了,那麼 P 不得不從其他的 P 裏拿一些 G 來執行。一般來說,如果 P 從其他的 P 那裏要拿任務的話,一般就拿 run queue 的一半,這就確保了每個 OS 線程都能充分的使用,如上圖。

GPM 創建相關

M 和 P 的數量如何確定?或者說何時會創建 M 和 P?

  1. P 的數量:

由啓動時環境變量 GOMAXPROCS 個 goroutine 在同時運行。

  1. M 的數量:

go 語言本身的限制:go 程序啓動時,會設置 M 的最大數量,默認 10000. 但是內核很難支持這麼多的線程數,所以這個限制可以忽略。runtime/debug 中的 SetMaxThreads 函數,設置 M 的最大數量 一個 M 阻塞了,會創建新的 M。M 與 P 的數量沒有絕對關係,一個 M 阻塞,P 就會去創建或者切換另一個 M,所以,即使 P 的默認數量是 1,也有可能會創建很多個 M 出來。

  1. P 何時創建:在確定了 P 的最大數量 n 後,運行時系統會根據這個數量創建 n 個 P。

  2. M 何時創建:沒有足夠的 M 來關聯 P 並運行其中的可運行的 G。比如所有的 M 此時都阻塞住了,而 P 中還有很多就緒任務,就會去尋找空閒的 M,而沒有空閒的,就會去創建新的 M。

GOMAXPROCS 設置對性能的影響

一般,GOMAXPROCS 的大小設置爲 CPU 的核數,使 Go 程序能充分利用 CPU。在 IO 密集型的應用裏,這樣設置可能性能並不是最好。理論上講當某個 Goroutine 進入系統調用時,會有一個新的 M 被啓用或創建,繼續佔滿 CPU。但 Go 舊的 M 被阻塞和新的 M 得到運行之間是有一定間隔的(延遲),所以在 IO 密集型應用中可以把 GOMAXPROCS 設置大一些,效果或許會更好。

加我微信,拉你進技術交流羣:wucs_dd

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