Go 爲什麼這麼 “快”?

作者:joellwang 騰訊 CSIG 後臺開發工程師

導語 | 本文主要介紹了 Go 程序爲了實現極高的併發性能,其內部調度器的實現架構(G-P-M 模型),以及爲了最大限度利用計算資源,Go 調度器是如何處理線程阻塞的場景。

01

怎麼讓我們的系統更快

隨着信息技術的迅速發展,單臺服務器處理能力越來越強,迫使編程模式由從前的串行模式升級到併發模型。

併發模型包含 IO 多路複用、多進程以及多線程,這幾種模型都各有優劣,現代複雜的高併發架構大多是幾種模型協同使用,不同場景應用不同模型,揚長避短,發揮服務器的最大性能。

多線程,因爲其輕量和易用,成爲併發編程中使用頻率最高的併發模型,包括後衍生的協程等其他子產品,也都基於它。

02

併發 ≠ 並行

併發 (concurrency) 和 並行 ( parallelism) 是不同的。

在單個  CPU  核上,線程通過時間片或者讓出控制權來實現任務切換,達到  " 同時 "  運行多個任務的目的,這就是所謂的併發。但實際上任何時刻都只有一個任務被執行,其他任務通過某種算法來排隊。

多核  CPU  可以讓同一進程內的  "多個線程"  做到真正意義上的同時運行,這纔是並行。

03

進程、線程、協程

進程:進程是系統進行資源分配的基本單位,有獨立的內存空間。

線程:線程是 CPU 調度和分派的基本單位,線程依附於進程存在,每個線程會共享父進程的資源。

協程:**協程是一種用戶態的輕量級線程,**協程的調度完全由用戶控制,協程間切換隻需要保存任務的上下文,沒有內核的開銷。

04

線程上下文切換

由於中斷處理,多任務處理,用戶態切換等原因會導致 CPU 從一個線程切換到另一個線程,切換過程需要保存當前進程的狀態並恢復另一個進程的狀態。

上下文切換的代價是高昂的,因爲在覈心上交換線程會花費很多時間。上下文切換的延遲取決於不同的因素,大概在在  50  到  100  納秒之間。考慮到硬件平均在每個核心上每納秒執行  12  條指令,那麼一次上下文切換可能會花費  600  到  1200  條指令的延遲時間。實際上,上下文切換佔用了大量程序執行指令的時間。

如果存在跨核上下文切換(Cross-Core Context Switch),可能會導致 CPU 緩存失效(CPU 從緩存訪問數據的成本大約  3  到  40  個時鐘週期,從主存訪問數據的成本大約  100  到  300  個時鐘週期),這種場景的切換成本會更加昂貴。

05

Golang 爲併發而生

Golang 從 2009 年正式發佈以來,依靠其極高運行速度和高效的開發效率,迅速佔據市場份額。Golang 從語言級別支持併發,通過輕量級協程 Goroutine 來實現程序併發運行。

Goroutine 非常輕量,主要體現在以下兩個方面:

上下文切換代價小: Goroutine 上下文切換隻涉及到三個寄存器(PC / SP / DX)的值修改;而對比線程的上下文切換則需要涉及模式切換(從用戶態切換到內核態)、以及 16 個寄存器、PC、SP… 等寄存器的刷新;

**內存佔用少:**線程棧空間通常是 2M,Goroutine 棧空間最小 2K;

Golang 程序中可以輕鬆支持 10w 級別的 Goroutine 運行,而線程數量達到 1k 時,內存佔用就已經達到 2G。

06

Go 調度器實現機制

Go 程序通過調度器來調度 **Goroutine 在內核線程上執行,**但是 Goroutine 並不直接綁定 OS 線程 M - Machine 運行,而是由 Goroutine Scheduler 中的  P - Processor (邏輯處理器)來作獲取內核線程資源的『中介』。

Go 調度器模型我們通常叫做 G-P-M 模型,他包括 4 個重要結構,分別是 G、P、M、Sched:

**G:Goroutine,**每個 Goroutine 對應一個 G 結構體,G 存儲 Goroutine 的運行堆棧、狀態以及任務函數,可重用。

G 並非執行體,每個 G 需要綁定到 P 才能被調度執行。

**P: Processor,**表示邏輯處理器,對 G 來說,P 相當於 CPU 核,G 只有綁定到 P 才能被調度。對 M 來說,P 提供了相關的執行環境 (Context),如內存分配狀態(mcache),任務隊列(G) 等。

P 的數量決定了系統內最大可並行的 G 的數量(前提:物理 CPU 核數  >= P 的數量)。

P 的數量由用戶設置的 GoMAXPROCS 決定,但是不論 GoMAXPROCS 設置爲多大,P 的數量最大爲 256。

**M: Machine,**OS 內核線程抽象,代表着真正執行計算的資源,在綁定有效的 P 後,進入 schedule 循環;而 schedule 循環的機制大致是從 Global 隊列、P 的 Local 隊列以及 wait 隊列中獲取。

**M 的數量是不定的,由 Go Runtime 調整,**爲了防止創建過多 OS 線程導致系統調度不過來,目前默認最大限制爲 10000 個。

M 並不保留 G 狀態,這是 G 可以跨 M 調度的基礎。

**Sched:Go 調度器,**它維護有存儲 M 和 G 的隊列以及調度器的一些狀態信息等。

調度器循環的機制大致是從各種隊列、P 的本地隊列中獲取 G,切換到 G 的執行棧上並執行 G 的函數,調用 Goexit 做清理工作並回到 M,如此反覆。

理解 M、P、G 三者的關係,可以通過經典的地鼠推車搬磚的模型來說明其三者關係:

地鼠 (Gopher) 的工作任務是:工地上有若干磚頭,地鼠藉助小車把磚頭運送到火種上去燒製。M 就可以看作圖中的地鼠,P 就是小車,G 就是小車裏裝的磚。

弄清楚了它們三者的關係,下面我們就開始重點聊地鼠是如何在搬運磚塊的。

**Processor(P):**根據用戶設置的  GoMAXPROCS 值來創建一批小車 (P)。**Goroutine(G):**通過 Go 關鍵字就是用來創建一個  Goroutine,也就相當於製造一塊磚 (G),然後將這塊磚(G) 放入當前這輛小車 (P) 中。

**Machine (M):**地鼠 (M) 不能通過外部創建出來,只能磚 (G) 太多了,地鼠 (M) 又太少了,實在忙不過來,剛好還有空閒的小車 (P) 沒有使用,那就從別處再借些地鼠 (M) 過來直到把小車 (P) 用完爲止。

這裏有一個地鼠 (M) 不夠用,從別處借地鼠 (M) 的過程,這個過程就是創建一個內核線程(M)。

**需要注意的是:**地鼠 (M)  如果沒有小車(P) 是沒辦法運磚的,小車 (P) 的數量決定了能夠幹活的地鼠 (M) 數量,在 Go 程序裏面對應的是活動線程數;

在 Go 程序裏我們通過下面的圖示來展示 G-P-M 模型:

P 代表可以 “並行” 運行的邏輯處理器,每個 P 都被分配到一個系統線程 M,G 代表 Go 協程。

Go 調度器中有兩個不同的運行隊列:全局運行隊列 (GRQ) 和本地運行隊列(LRQ)。

每個 P 都有一個 LRQ,用於管理分配給在 P 的上下文中執行的 Goroutines,這些 Goroutine 輪流被和 P 綁定的 M 進行上下文切換。GRQ 適用於尚未分配給 P 的 Goroutines。

**從上圖可以看出,G 的數量可以遠遠大於 M 的數量,換句話說,Go 程序可以利用少量的內核級線程來支撐大量 Goroutine 的併發。**多個 Goroutine 通過用戶級別的上下文切換來共享內核線程 M 的計算資源,但對於操作系統來說並沒有線程上下文切換產生的性能損耗。

爲了更加充分利用線程的計算資源,Go 調度器採取了以下幾種調度策略:

任務竊取(work-stealing)

我們知道,現實情況有的 Goroutine 運行的快,有的慢,那麼勢必肯定會帶來的問題就是,忙的忙死,閒的閒死,Go 肯定不允許摸魚的 P 存在,勢必要充分利用好計算資源。爲了提高 Go 並行處理能力,調高整體處理效率,當每個 P 之間的 G 任務不均衡時,調度器允許從 GRQ,或者其他 P 的 LRQ 中獲取 G 執行。

減少阻塞

如果正在執行的 Goroutine 阻塞了線程 M 怎麼辦?P 上 LRQ 中的 Goroutine 會獲取不到調度麼?

在 Go 裏面阻塞主要分爲一下 4 種場景:

場景 1:由於原子、互斥量或通道操作調用導致  Goroutine  阻塞,調度器將把當前阻塞的 Goroutine 切換出去,重新調度 LRQ 上的其他 Goroutine;

場景 2:由於網絡請求和 IO 操作導致  Goroutine  阻塞,這種阻塞的情況下,我們的 G 和 M 又會怎麼做呢?

Go 程序提供了**網絡輪詢器(NetPoller)**來處理網絡請求和 IO 操作的問題,其後臺通過 kqueue(MacOS),epoll(Linux)或  iocp(Windows)來實現 IO 多路複用。

通過使用 NetPoller 進行網絡系統調用,調度器可以防止  Goroutine  在進行這些系統調用時阻塞 M。這可以讓 M 執行 P 的  LRQ  中其他的  Goroutines,而不需要創建新的 M。有助於減少操作系統上的調度負載。

**下圖展示它的工作原理:**G1 正在 M 上執行,還有 3 個 Goroutine 在 LRQ 上等待執行。網絡輪詢器空閒着,什麼都沒幹。

接下來,G1 想要進行網絡系統調用,因此它被移動到網絡輪詢器並且處理異步網絡系統調用。然後,M 可以從 LRQ 執行另外的 Goroutine。此時,G2 就被上下文切換到 M 上了。

最後,異步網絡系統調用由網絡輪詢器完成,G1 被移回到 P 的 LRQ 中。一旦 G1 可以在 M 上進行上下文切換,它負責的 Go 相關代碼就可以再次執行。這裏的最大優勢是,執行網絡系統調用不需要額外的 M。網絡輪詢器使用系統線程,它時刻處理一個有效的事件循環。

這種調用方式看起來很複雜,值得慶幸的是,Go 語言將該 “複雜性” 隱藏在 Runtime 中:Go 開發者無需關注 socket 是否是  non-block 的,也無需親自注冊文件描述符的回調,只需在每個連接對應的 Goroutine 中以 “block I/O” 的方式對待 socket 處理即可,實現了 goroutine-per-connection 簡單的網絡編程模式(但是大量的 Goroutine 也會帶來額外的問題,比如棧內存增加和調度器負擔加重)。

用戶層眼中看到的 Goroutine 中的 “block socket”,實際上是通過 Go runtime 中的 netpoller 通過 Non-block socket + I/O 多路複用機制“模擬” 出來的。Go 中的 net 庫正是按照這方式實現的。

**場景 3:**當調用一些系統方法的時候,如果系統方法調用的時候發生阻塞,這種情況下,網絡輪詢器(NetPoller)無法使用,而進行系統調用的  Goroutine  將阻塞當前 M。

讓我們來看看同步系統調用(如文件 I/O)會導致 M 阻塞的情況:G1 將進行同步系統調用以阻塞 M1。

調度器介入後:識別出 G1 已導致 M1 阻塞,此時,調度器將 M1 與 P 分離,同時也將 G1 帶走。然後調度器引入新的 M2 來服務 P。此時,可以從 LRQ 中選擇 G2 並在 M2 上進行上下文切換。

阻塞的系統調用完成後:G1 可以移回 LRQ 並再次由 P 執行。如果這種情況再次發生,M1 將被放在旁邊以備將來重複使用**。**

**場景 4:**如果在 Goroutine 去執行一個 sleep 操作,導致 M 被阻塞了。

Go 程序後臺有一個監控線程 sysmon,它監控那些長時間運行的 G 任務然後設置可以強佔的標識符,別的 Goroutine 就可以搶先進來執行。

只要下次這個 Goroutine 進行函數調用,那麼就會被強佔,同時也會保護現場,然後重新放入 P 的本地隊列裏面等待下次執行。

07

小結

本文主要從 Go 調度器架構層面上介紹了 G-P-M 模型,通過該模型怎樣實現少量內核線程支撐大量 Goroutine 的併發運行。以及通過 NetPoller、sysmon 等幫助 Go 程序減少線程阻塞,充分利用已有的計算資源,從而最大限度提高 Go 程序的運行效率。

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