go 获取内核个数_图解Go运行时调度器
多goroutines形式的Go并發是編寫現代并發軟件的一種非常方便的方法,但是您的Go程序是如何高效地運行這些goroutines的呢?
在這篇文章中,我們將深入Go運行時底層,從設計角度了解Go運行時調度程序是如何實現其魔法的,并運用這些原理去解釋在Go性能調試過程中產生的Go調度程序跟蹤信息。
所有的工程奇跡都源于需要。因此,要了解 為什么需要一個Go運行時調度程序 以及 它是如何工作的 ,我們可以讓時間回到操作系統興起的那個時代,回顧操作系統的歷史可以使我們深入的了解問題的根源。如果不了解問題的根源,就沒有解決它的希望。這就是歷史所能做的。
一. 操作系統的歷史
多道程序的目的是使CPU和I/O重疊(overlap)。(譯注:多道程序出現之前,當操作系統執行I/O操作時,CPU是空閑的;多道程序的引入實現了在一個程序占用CPU的時候,另一個程序在執行I/O操作)
那怎么實現多道程序(的CPU與I/O重疊)呢?兩種方式:多道批處理系統和分時系統。
- 多道批處理系統IBM OS/MFT(具有固定數量的任務的多道程序)IBM OS/MVT(具有可變數量的任務的多道程序)在這里,每個作業(job)僅獲得其所需的內存量。隨著job的進出,內存的劃分會發生變化。
- 分時這是一種多道程序設計,可以在作業之間快速切換。決定何時切換以及切換到哪個作業的過程就稱為 調度(scheduling) 。
當前,大多數操作系統使用分時調度程序。
那么這些調度程序將用來調度什么實體(entity)呢?
- 不同的正在執行的程序(即進程process)
- 或作為進程子集存在使用CPU的基本單元:線程
但是在這些實體的切換是有代價的。
- 調度成本
圖: 進程和線程的狀態變量
因此,使用一個包含多個線程的進程的效率更高,因為進程創建既耗時又耗費資源。但是隨后出現了多線程問題: C10k 成為主要問題。
例如,如果 將調度周期定為10ms(毫秒) ,并且有2個線程,則每個線程將分別獲得5ms。如果您有5個線程,則每個線程將獲得2ms。但是,如果有1000個線程怎么辦?給每個線程一個10μs(微秒)的時間片?錯,這樣做很愚蠢,因為您將花費大量時間進行上下文切換,但是真正要完成的工作卻進展緩慢或停滯不前。
您需要限制時間片的長度。在最后一種情況下,如果最小時間片為2ms并且有1000個線程,則調度周期需要增加到2s(1000 2ms)。如果有10,000個線程,則調度程序周期為20秒(10000 2ms)。在這個簡單的示例中,如果每個線程都將分配給它的時間片用完,那么所有線程都完成一次運行需要20秒。因此,我們需要一些可以使并發成本降低而又不會造成過多開銷的東西。
- 用戶層線程線程完全由運行時系統(用戶級庫)管理。理想情況下,快速高效:切換線程的代價不比函數調用多多少。操作系統內核對用戶層線程一無所知,并像對待單線程進程(single-threaded process)一樣對其進行管理。
在Go中,我們知道這樣的用戶層線程被稱為“Goroutine”。
- Goroutine
圖: goroutine vs. 線程
goroutine是由Go運行時管理的輕量級線程(lightweight thread)。要啟動一個新的goroutine,只需在函數前面使用 go 關鍵字: go add(a, b) 。
- Goroutine之旅
https://play.golang.org/p/73lESLiva0A
您能猜出上面代碼片段的輸出嗎?
loop i is - 10loop i is - 0loop i is - 1loop i is - 2loop i is - 3loop i is - 4loop i is - 5loop i is - 6loop i is - 7loop i is - 8loop i is - 9Hello, Welcome to Go如果我們看一下輸出的一種組合,你可能馬上就會有兩個問題:
- 11個goroutine如何并行運行?魔法?
- goroutine以什么順序運行?
圖:gopher版奇異博士
上面的這兩個提問給我們帶來了問題。
- 問題概述如何將這些goroutines分配到在CPU處理器上運行的多個操作系統線程上運行?這些goroutines應該以什么順序運行才能保證公平?
本文后續的討論將主要圍繞Go運行時調度程序從設計角度如何解決這些問題。但是,與所有問題一樣,我們的討論也需要定義一個明確的邊界。否則,問題陳述可能太含糊,無法形成結論。調度程序可能針對多個目標中的一個或多個,對于我們來說,我們將自己限制在以下需求之內:
讓我們開始為調度程序建模,以逐步解決這些問題。
二. Goroutine調度程序模型 (譯者自行加的標題)
1. 模型概述(譯者自行加的標題)
a) 一個線程執行一個Goroutine
局限性:
- 并行和可擴展并行(是的)可擴展(不是真的)
- 每個進程不能擴展到數百萬個goroutine(C10M)。
b) M:N線程—混合線程
M個操作系統內核線程執行N個“goroutine”
圖: M個內核線程執行N個goroutine
實際執行代碼和并行執行都需要內核線程。但是線程創建起來很昂貴,因此我們將N個goroutines映射到M個內核線程上去執行。Goroutine是Go代碼,因此我們可以完全控制它。而且它在用戶空間中,創建起來很便宜。
但是由于操作系統對goroutine一無所知。因此每個goroutine都有一個狀態, 以幫助調度器根據goroutine狀態知道要運行哪個goroutine 。與內核線程的狀態信息相比,goroutine的狀態信息很小,因此goroutine的上下文切換變得非常快。
- 正在運行(Running) – 當前在內核線程上運行的goroutine。
- 可運行(Runnable) – 等待內核線程來運行的goroutine。
- 已阻塞(Blocked) – 等待某些條件的Goroutine(例如,阻塞在channel操作,系統調用,互斥鎖上的goroutine)
圖: 2個線程同時運行2個goroutine
因此,Go運行時調度器通過將N個Goroutine多路復用到M個內核線程的方式來管理處于各種不同狀態的goroutines。
2. 簡單的M:N調度器
在我們簡單的M:N調度器中,我們有一個全局運行隊列(global run queue),某些操作將一個新的goroutine放入運行隊列。M個內核線程訪問調度程序從“運行隊列”中獲取并運行goroutine。多個線程正在嘗試訪問相同的內存區域,因此使用互斥鎖來同步對該運行隊列的訪問。
圖: 簡單的M:N調度器
但是,那些已阻塞的goroutine在哪里?
下面是goroutine可能會阻塞的情況:
那么我們將這些阻塞的goroutine放在哪里呢?— 將這些阻塞的goroutine放置在哪里的設計決策基本上是圍繞一個基本原理進行的:
阻塞的goroutine不應阻塞底層內核線程!(避免線程上下文切換的成本)
channel操作期間阻塞的Goroutine
每個channel都有一個 recvq(waitq) ,用于存儲試圖從該channel讀取數據而阻塞的goroutine。
Sendq(waitq)存儲試圖將數據發送到channel而被阻止的goroutine 。(channel實現原理:-https://codeburst.io/diving-deep-into-the-golang-channels-549fd4ed21a8)
圖: channel操作期間阻塞的Goroutine
channel本身會將channel操作后的未阻塞goroutine放入“運行”隊列(run queue)。
圖: channel操作后未阻礙的goroutine
那系統調用呢?
首先,讓我們看一下阻塞系統調用。系統調用會阻塞底層內核線程,因此我們無法在該線程上調度任何其他Goroutine。
隱含阻塞系統調用可降低并行度。
圖: 阻塞系統調用可降低并行度
一旦發生阻塞系統調用,我們無法再在M2線程上安排任何其他Goroutine運行,從而導致CPU浪費。由于我們有工作要做,但沒法運行它。
恢復并行度的方法是在進入系統調用時,我們可以喚醒另一個線程,該線程將從運行隊列中選擇可運行的goroutine。
圖: 恢復并行度的方法
但是現在,系統調用完成后,我們有超額等待調度的goroutine。因此,我們不會立即運行從阻塞系統調用中返回的goroutine。我們會將其放入調度程序的運行隊列中。
圖: 避免超額等待調度
因此,在程序運行時,線程數遠大于cpu核數。盡管沒有明確說明,線程數大于cpu核數,并且所有空閑線程也由運行時管理,以避免啟動過多的線程。
https://golang.org/pkg/runtime/debug/#SetMaxThreads
初始設置為10,000個線程,如果超過10,000個線程,程序將崩潰。
非阻塞系統調用-將goroutine阻塞在 Integrated runtime poller 上 ,并釋放線程以運行另一個goroutine。
例如,在非阻塞I/O(例如HTTP調用)的情況下。由于資源尚未準備就緒,第一個syscall將不會成功,這將迫使Go使用network poller并將goroutine暫停。
部分net.Read函數的實現:
n, err := syscall.Read(fd.Sysfd, p) if err != nil { n = 0 if err == syscall.EAGAIN && fd.pd.pollable() { if err = fd.pd.waitRead(fd.isFile); err == nil { continue } } }一旦完成第一個系統調用并明確指出資源尚未準備就緒,goroutine將暫停,直到network poller通知它資源已準備就緒。在這種情況下,線程M將不會被阻塞。
Poller將基于操作系統使用select/kqueue/epoll/IOCP等機制來知道哪個文件描述符已準備好,一旦文件描述符準備好進行讀取或寫入,它將把goroutine放回到運行隊列中。
還有一個Sysmon OS線程,如果超過10ms未輪詢網絡,它就將定期輪詢網絡,并將已就緒的G添加到隊列中。
基本上所有goroutine都被阻塞在下面操作上:
有某種隊列,可以幫助調度這些goroutine。
現在,運行時擁有具有以下功能的調度程序。
- 它可以處理并行執行(多線程)。
- 處理阻塞系統調用和網絡I/O。
- 處理阻塞在用戶級別(在channel上)的調用。
但這不是可伸縮的(scalable)。
圖: 使用Mutex同步全局運行隊列
您可以通過Mutex同步全局運行隊列,但最終會遇到一些問題,例如
使用分布式調度程序解決可伸縮性問題。
分布式調度程序-每個線程一個運行隊列
圖: 分布式運行隊列的調度程序
這樣,我們可以看到的直接好處是,每個線程的本地運行隊列(local run queue)現在都沒有使用mutex。仍然有一個帶有mutex的全局運行隊列,但僅在特殊情況下使用。 它不會影響可伸縮性。
但是現在,我們有多個運行隊列。
我們應該從哪里運行下一個goroutine?
在Go中,輪詢順序定義如下:
1. 本地運行隊列
2. 全局運行隊列
3. 網絡輪詢器
4. 工作偷竊(work stealing)
即首先檢查本地運行隊列,如果為空則檢查全局運行隊列,然后檢查網絡輪詢器,最后進行“偷竊工作”。到目前為止,我們對1,2,3有了一些概述。讓我們看一下“工作偷竊(work stealing)”。
工作偷竊
如果本地工作隊列為空,請嘗試“從其他隊列中偷竊工作”
圖: 偷竊工作
當一個線程有太多工作要做而另一個線程空閑時,工作偷竊可以解決這個問題。在Go中,如果本地隊列為空,工作偷竊將嘗試滿足以下條件之一。
- 從全局隊列中拉取工作。
- 從網絡輪詢器中拉取工作
- 從其他線程的本地隊列中偷竊工作
到目前為止,Go運行時的調度器具有以下功能:
- 它可以處理并行執行(使用多線程)。
- 處理阻塞系統調用和網絡I/O。
- 處理用戶級別(比如:在channel)的阻塞調用。
- 可伸縮擴展(scalable)
但這仍不是最有效的。
還記得我們在阻塞系統調用中恢復并行度的方式嗎?
圖: 系統調用操作
它暗示在一個系統調用中我們可以有多個內核線程(可以是10或1000),這可能會比cpu核數多很多。這個方案將最終在以下期間產生了恒定的開銷:
- 偷竊工作時,它必須同時掃描所有內核線程(空閑的和運行goroutine的)本地運行隊列,并且大多數都將是空閑的。
- 垃圾回收,內存分配器都會遇到相同的掃描問題。(https://blog.learngoprogramming.com/a-visual-guide-to-golang-memory-allocator-from-ground-up-e132258453ed)
使用M:P:N線程克服效率問題。
M:P:N(3級調度程序)— 引入邏輯處理器P
P —表示處理器, 可以將其視為在線程上運行的本地調度程序
圖: M:P:N模型
邏輯進程P的數量始終是固定的。(默認為當前進程可以使用的邏輯CPU數量)
然后,我們將本地運行隊列(LRQ)放入固定數量的邏輯處理器(P)中(譯者注:而不是每個內核線程一個本地運行隊列)。
圖: 分布式三級運行隊列調度程序
Go運行時將首先根據計算機的邏輯CPU數量(或根據請求)創建固定數量的邏輯處理器P。
每個goroutine(G)將在分配了邏輯CPU(P)的OS線程(M)上運行。
所以現在我們在以下期間沒有了恒定的開銷:
- 偷竊工作 -只需掃描固定數量的邏輯處理器(P)的本地運行隊列。
- 垃圾回收,內存分配器也將獲得相同的好處。
使用固定邏輯處理器(P)的系統調用呢?
Go通過將它們包裝在運行時中來優化系統調用(無論是否阻塞)。
圖: 阻塞系統調用的包裝器
阻塞SYSCALL方法封裝在runtime.entersyscall(SB)和 runtime.exitsyscall(SB)之間。
從字面上看,某些邏輯在進入系統調用之前被執行,而某些邏輯在系統調用返回之后執行。進行阻塞的系統調用時,此包裝器將自動將P與線程M(即將執行阻塞系統調用的線程)解綁,并允許另一個線程在其上運行。
圖:阻塞Syscall的M交出P
這使得Go運行時可以高效地處理阻塞的系統調用,而無需增加運行隊列(譯注:本地運行隊列數量始終是和P數量一致的)。
一旦阻塞系統調用返回,會發生什么?
- 運行時會嘗試獲取之前綁定的那個P,然后繼續執行。
- 運行時嘗試在P空閑列表中獲取一個P并恢復執行。
- 運行時將goroutine放在全局隊列中,并將關聯的M放回M空閑列表。
自旋線程和空閑線程
當M2線程在syscall返回后變得空閑時。如何處理這個空閑的M2線程。從理論上講,如果線程完成了所需的操作,則應將其銷毀,然后再安排進程中的其他線程到CPU上執行。這就是我們通常所說的操作系統中線程的“搶占式調度”。
考慮上述syscall中的情況。如果我們銷毀了M2線程,而同時M3線程即將進入syscall。此時,在OS創建新的內核線程并將其調度執行之前,我們無法處理可運行的goroutine。頻繁的線程前搶占操作不僅會增加OS的負載,而且對于性能要求更高的程序幾乎是不可接受的。
因此,為了適當地利用操作系統的資源并防止頻繁的線程搶占給操作系統帶來的負擔,我們不會銷毀內核線程M2,而是使其執行自旋操作并以備將來使用。盡管這看起來是在浪費一些資源。但是,與線程之間的頻繁搶占以及頻繁的創建和銷毀操作相比,“空閑線程”要付出的代價更少。
Spinning Thread(自旋線程)— 例如,在具有一個內核線程M(1)和一個邏輯處理器(P)的Go程序中,如果正在執行的M被syscall阻塞,則運行時會請求與P數量相同的“Spinning Threads”以允許等待的可運行goroutine繼續執行。因此,在此期間,內核線程的數量M將大于P的數量(自旋線程+阻塞線程)。因此,即使將runtime.GOMAXPROCS的值設置為1,程序也將處于多線程狀態。
調度中的公平性如何?—公平地選擇下一個要執行的goroutine
與許多其他調度程序一樣,Go也具有公平性約束,并且由goroutine的實現所強加,因為Runnable goroutine應該最終得到調度并運行。
這是Go Runtime Scheduler的四個典型的公平性約束:
任何運行時間超過10ms的goroutine都被標記為可搶占(軟限制)。但是,搶占僅在函數執行開始處才能完成。Go當前在函數開始處中使用了由編譯器插入的協作搶占點。
- 無限循環 – 搶占(約10毫秒的時間片)- 軟限制
但請小心無限循環,因為Go的調度程序不是搶先的(直到Go 1.13)。如果循環不包含任何搶占點(例如函數調用或分配內存),則它們將阻止其他goroutine的運行。一個簡單的例子是:
package mainfunc main() { go println("goroutine ran") for {}}如果你運行:
GOMAXPROCS=1 go run main.go直到Go(1.13)才可能打印該語句。由于缺少搶占點,main Goroutine將獨占處理器。
- 本地運行隊列 -搶占(?10ms時間片)- 軟限制
- 通過每61次調度就檢查一次全局運行隊列,可以避免全局運行隊列處于“饑餓”狀態。
- 網絡輪詢器饑餓 后臺線程會在主工作線程未輪詢的情況下偶爾會輪詢網絡。
Go 1.14有一個新的 “非合作搶占” 機制。
有了這種機制,Go運行時便有了具有所有必需功能的Scheduler。
- 它可以處理并行執行(多線程)。
- 處理阻塞系統調用和網絡I/O。
- 處理用戶級別(在channel上)的阻塞調用。
- 可擴展
- 高效
- 公平
這提供了大量的并發性,并且始終嘗試實現最大的利用率和最小的延遲。
現在,我們總體上對Go運行時調度程序有了一些了解,我們如何使用它?Go為我們提供了一個跟蹤工具,即調度程序跟蹤(scheduler trace),目的是提供有關調度行為的信息并用來調試與goroutine調度器伸縮性相關的問題。
三. 調度器跟蹤
使用 GODEBUG=schedtrace=DURATION 環境變量運行Go程序以啟用調度程序跟蹤。(DURATION是以毫秒為單位的輸出周期。)
圖:以100ms粒度對schedtrace輸出采樣
有關調度器跟蹤的內容, Go Wiki 擁有更多信息。
總結
以上是生活随笔為你收集整理的go 获取内核个数_图解Go运行时调度器的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: eclipse热部署_Spring Bo
- 下一篇: 平衡二叉树平衡因子_数据结构:平衡二叉树