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