本文主要介紹了 Go 程式為了實現極高的並發效能,其內部調度器的實作架構(G-P-M 模型),以及為了最大限度地利用運算資源,Go 調度器是如何處理執行緒阻塞的場景。
怎麼讓我們的系統更快
隨著資訊科技的快速發展,單一伺服器處理能力越來越強,迫使程式設計模式由從前的串行模式升級到並發模型。
並發模型包含IO 多路復用、多進程以及多線程,這幾種模型都各有優劣,現代複雜的高並發架構大多是幾種模型協同使用,不同場景應用不同模型,揚長避短,發揮伺服器的最大效能。
而多線程,因為其輕量且易用,成為並發程式設計中使用頻率最高的並發模型,包括後衍生的協程等其他子產品,也都基於它。
並發 ≠ 並行
並發 (concurrency) 和 並行 ( parallelism) 是不同的。
在單一 CPU 核上,執行緒透過時間片或讓出控制權來實現任務切換,達到 "同時" 執行多個任務的目的,這就是所謂的並發。但實際上任何時刻都只有一個任務被執行,其他任務則透過某種演算法來排隊。
多核心 CPU 可以讓同一進程內的 "多個執行緒" 做到真正意義上的同時運行,這才是並行。
進程、執行緒、協程
進程:進程是系統進行資源分配的基本單位,有獨立的記憶體空間。
執行緒:執行緒是 CPU 調度和分派的基本單位,執行緒依附於行程存在,每個執行緒會共享父行程的資源。
協程:協程是一種用戶態的輕量級線程,協程的調度完全由使用者控制,協程間切換只需要保存任務的上下文,沒有核心的開銷。
線程上下文切換
由於中斷處理,多任務處理,用戶態切換等原因會導致CPU 從一個線程切換到另一個線程,切換過程需要保存當前進程的狀態並恢復另一個進程的狀態。
上下文切換的代價是高昂的,因為在核心上交換執行緒會花費很多時間。上下文切換的延遲取決於不同的因素,大概在 50 到 100 納秒之間。考慮到硬體平均在每個核心上每納秒執行 12 條指令,那麼一次上下文切換可能會花費 600 到 1200 條指令的延遲時間。實際上,上下文切換佔用了大量程式執行指令的時間。
如果存在跨核上下文切換(Cross-Core Context Switch),可能會導致CPU 快取失敗(CPU 從快取存取資料的成本大約 3 到 40 個時鐘週期,並從主記憶體存取資料的成本大約100 到 300 個時鐘週期),這種場景的切換成本會更昂貴。
Golang 為並發而生
Golang 從 2009 年正式發布以來,依靠其極高運行速度和高效的開發效率,迅速佔據市場份額。 Golang 從語言層級支援並發,透過輕量級協程 Goroutine 來實現程式並發運行。
Goroutine 非常輕量,主要體現在以下兩個方面:
上下文切換代價小: Goroutine 上下文切換只涉及三個暫存器(PC / SP / DX)的值修改;而對比線程的上下文切換則需要涉及模式切換(從用戶態切換到內核狀態)、以及16 個寄存器、PC、SP…等寄存器的刷新;
內存佔用少:線程棧空間通常是2M,Goroutine 堆疊空間最小2K;
Golang 程式中可以輕鬆支援10w 等級的Goroutine 運行,而執行緒數量達到1k 時,記憶體佔用就已經達到2G。
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 的本地隊列裡面等待下次執行。
小結
本文主要從 Go 調度器架構層面上介紹了 G-P-M 模型,透過該模型如何實現少量核心執行緒支撐大量 Goroutine 的並發運行。以及透過 NetPoller、sysmon 等幫助 Go 程式減少執行緒阻塞,充分利用現有的運算資源,從而最大限度地提高 Go 程式的運作效率。
更多go語言知識請關注php中文網go語言教學欄位。
以上是Go 為什麼這麼'快”的詳細內容。更多資訊請關注PHP中文網其他相關文章!