<acronym id="s8ci2"><small id="s8ci2"></small></acronym>
<rt id="s8ci2"></rt><rt id="s8ci2"><optgroup id="s8ci2"></optgroup></rt>
<acronym id="s8ci2"></acronym>
<acronym id="s8ci2"><center id="s8ci2"></center></acronym>
0
  • 聊天消息
  • 系統消息
  • 評論與回復
登錄后你可以
  • 下載海量資料
  • 學習在線課程
  • 觀看技術視頻
  • 寫文章/發帖/加入社區
會員中心
創作中心

完善資料讓更多小伙伴認識你,還能領取20積分哦,立即完善>

3天內不再提示

goroutine調度器的概念、演進及場景分析

馬哥Linux運維 ? 來源:馬哥Linux運維 ? 作者:馬哥Linux運維 ? 2022-10-12 09:42 ? 次閱讀

goroutine 調度器的概念

說到“調度”,首先會想到操作系統對進程、線程的調度。操作系統調度器會將系統中的多個線程按照一定算法調度到物理 CPU 上去運行。

傳統的編程語言比如 C、C++ 等的并發實現實際上就是基于操作系統調度的,即程序負責創建線程,操作系統負責調度。

盡管線程的調度方式相對于進程來說,線程運行所需要資源比較少,在同一進程中進行線程切換效率會高很多,但實際上多線程開發設計會變得更加復雜,要考慮很多同步競爭等問題,如鎖、競爭沖突等。

線程是操作系統調度時的最基本單元,而 Linux 在調度器并不區分進程和線程的調度,只是說線程調度因為資源少,所以切換的效率比較高。

使用多線程編程會遇到以下問題:

并發單元間通信困難,易錯:多個 thread 之間的通信雖然有多種機制可選,但用起來是相當復雜;并且一旦涉及到共享內存,就會用到各種 lock,一不小心就會出現死鎖的情況。

對于線程池的大小不好確認,在請求量大的時候容易導致 OOM 的情況

雖然線程比較輕量,但是在調度時也有比較大的額外開銷。每個線程會都占用 1 兆以上的內存空間,在對線程進行切換時不僅會消耗較多的內存,恢復寄存器中的內容還需要向操作系統申請或者銷毀對應的資源,每一次線程上下文的切換仍然需要一定的時間(us 級別)

對于很多網絡服務程序,由于不能大量創建 thread,就要在少量 thread 里做網絡多路復用,例如 JAVA 的Netty 框架,寫起這樣的程序也不容易。

這便有了“協程”,線程分為內核態線程和用戶態線程,用戶態線程需要綁定內核態線程,CPU 并不能感知用戶態線程的存在,它只知道它在運行1個線程,這個線程實際是內核態線程。

用戶態線程實際有個名字叫協程(co-routine),為了容易區分,使用協程指用戶態線程,使用線程指內核態線程。

協程跟線程是有區別的,線程由CPU調度是搶占式的,協程由用戶態調度是協作式的,一個協程讓出 CPU 后,才執行下一個協程。

Go中,協程被稱為 goroutine(但其實并不完全是協程,還做了其他方面的優化),它非常輕量,一個 goroutine 只占幾 KB,并且這幾 KB 就足夠 goroutine 運行完,這就能在有限的內存空間內支持大量 goroutine,支持了更多的并發。雖然一個 goroutine 的棧只占幾 KB,但實際是可伸縮的,如果需要更多內容,runtime會自動為 goroutine 分配。

而將所有的 goroutines 按照一定算法放到 CPU 上執行的程序就稱為 goroutine 調度器或 goroutine scheduler。

不過,一個 Go 程序對于操作系統來說只是一個用戶層程序,對于操作系統而言,它的眼中只有 thread,它并不知道有什么叫 Goroutine 的東西的存在。goroutine 的調度全要靠 Go 自己完成,所以就需要 goroutine 調度器來實現 Go 程序內 goroutine 之間的 CPU 資源調度。

在操作系統層面,Thread 競爭的 CPU 資源是真實的物理 CPU,但對于 Go 程序來說,它是一個用戶層程序,它本身整體是運行在一個或多個操作系統線程上的,因此 goroutine 們要競爭的所謂 “CPU” 資源就是操作系統線程。

這樣 Go scheduler 的要做的就是:將 goroutines 按照一定算法放到不同的操作系統線程中去執行。這種在語言層面自帶調度器的,稱之為原生支持并發。

goroutine 調度器的演進

調度器的任務是在用戶態完成 goroutine 的調度,而調度器的實現好壞,對并發實際有很大的影響。

G-M模型

現在的 Go語言調度器是 2012 年重新設計的,在這之前的調度器稱為老調度器,老調度器采用的是 G-M 模型,在這個調度器中,每個 goroutine 對應于 runtime 中的一個抽象結構:G,而 os thread 作為物理 CPU 的存在而被抽象為一個結構:

M(machine)。M 想要執行 G、放回 G 都必須訪問全局 G 隊列,并且 M 有多個,即多線程訪問同一資源需要加鎖進行保證互斥/同步,所以全局 G 隊列是有互斥鎖進行保護的。

a7de3986-4972-11ed-a3b6-dac502259ad0.png

這個結構雖然簡單,但是卻存在著許多問題。它限制了 Go 并發程序的伸縮性,尤其是對那些有高吞吐或并行計算需求的服務程序。主要體現在如下幾個方面:

單一全局互斥鎖(Sched.Lock)和集中狀態存儲的存在導致所有 goroutine 相關操作,比如:創建、重新調度等都要上鎖,這會造成激烈的鎖競爭

goroutine 傳遞問題:M 經常在 M 之間傳遞可運行的 goroutine,這導致調度延遲增大以及額外的性能損耗

每個 M 做內存緩存,導致內存占用過高,數據局部性較差

系統調用導致頻繁的線程阻塞和取消阻塞操作增加了系統開銷

所以用了 4 年左右就被替換掉了。

G-P-M 模型

面對之前調度器的問題,Go 設計了新的調度器,并在其中引入了 P(Processor),另外還引入了任務竊取調度的方式(work stealing)

P:Processor,它包含了運行 goroutine 的資源,如果線程想運行 goroutine,必須先獲取 P,P 中還包含了可運行的 G 隊列。work stealing:當 M 綁定的 P 沒有可運行的 G 時,它可以從其他運行的 M 那里偷取G。G-P-M 模型的結構如下圖:

a80a413e-4972-11ed-a3b6-dac502259ad0.jpg

從上往下是調度器的4個部分:

全局隊列(Global Queue):存放等待運行的 G。P 的本地隊列:同全局隊列類似,存放的也是等待運行的G,存的數量有限,不超過256個。新建 G 時,G 優先加入到 P 的本地隊列,如果隊列滿了,則會把本地隊列中一半的 G 移動到全局隊列。P列表:所有的 P 都在程序啟動時創建,并保存在數組中,最多有 GOMAXPROCS 個。M:線程想運行任務就得獲取 P,從 P 的本地隊列獲取 G,P 隊列為空時,M 也會嘗試從全局隊列拿一批 G放到 P 的本地隊列,或從其他 P 的本地隊列偷一半放到自己 P 的本地隊列。M 運行 G,G 執行之后,M 會從 P 獲取下一個 G,不斷重復下去。Goroutine 調度器和 OS 調度器是通過 M 結合起來的,每個 M 都代表了1個內核線程,OS 調度器負責把內核線程分配到 CPU 的核上執行。

有關 P 和 M 的個數問題

P 的數量

由啟動時環境變量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 決定。這意味著在程序執行的任意時刻都只有 $GOMAXPROCS 個 goroutine 在同時運行。

M 的數量

go 語言本身的限制:go 程序啟動時,會設置 M 的最大數量,默認 10000。但是內核很難支持這么多的線程數,所以這個限制可以忽略。

runtime/debug 中的 SetMaxThreads 函數,可以設置 M 的最大數量

一個 M 阻塞了,會創建新的 M。

M 與 P 的數量沒有絕對關系,一個 M 阻塞,P 就會去創建或者切換另一個 M,所以,即使 P 的默認數量是 1,也有可能會創建很多個 M 出來。

搶占式調度

G-P-M 模型中還實現了搶占式調度,所謂搶占式調度指的是在 coroutine 中要等待一個協程主動讓出 CPU 才執行下一個協程,在 Go 中,一個 goroutine 最多占用CPU 10ms,防止其他 goroutine 被餓死,這也是 goroutine 不同于 coroutine 的一個地方。在 goroutine 中先后實現了兩種搶占式調度算法,分別是基于協作的方式和基于信號的方式。

基于協作的搶占式調度

G-P-M 模型的實現是 Go scheduler 的一大進步,但此時的調度器仍然有一個問題,那就是不支持搶占式調度,導致一旦某個 G 中出現死循環或永久循環的代碼邏輯,那么 G 將永久占用分配給它的 P 和 M,位于同一個 P 中的其他 G 將得不到調度,出現“餓死”的情況。當只有一個 P 時(GOMAXPROCS=1)時,整個 Go 程序中的其他 G 都會被餓死。所以后面 Go 設計團隊在 Go 1.2 中實現了基于協作的搶占式調度。

這種搶占式調度的原理則是在每個函數或方法的入口,加上一段額外的代碼,讓 runtime 有機會檢查是否需要執行搶占調度。

基于協作的搶占式調度的工作原理大致如下:

編譯器會在調用函數前插入一個 runtime.morestack 函數

Go 語言運行時會在垃圾回收暫停程序、系統監控發現 Goroutine 運行超過 10ms 時發出搶占請求,此時會設置一個 StackPreempt 字段值為 StackPreempt ,標示當前 Goroutine 發出了搶占請求。

當發生函數調用時,可能會執行編譯器插入的 runtime.morestack 函數,它調用的 runtime.newstack 會檢查 Goroutine 的 stackguard0 字段是否為 StackPreempt

如果 stackguard0 是 StackPreempt,就會觸發搶占讓出當前線程

這種實現方式雖然增加了運行時的復雜度,但是實現相對簡單,也沒有帶來過多的額外開銷,所以在 Go 語言中使用了 10 幾個版本。因為這里的搶占是通過編譯器插入函數實現的,還是需要函數調用作為入口才能觸發搶占,所以這是一種協作式的搶占式調度。這種解決方案只能說局部解決了“餓死”問題,對于沒有函數調用,純算法循環計算的 G,scheduler 依然無法搶占。

基于信號的搶占式調度

Go 語言在 1.14 版本中實現了非協作的搶占式調度,在實現的過程中重構已有的邏輯并為 Goroutine 增加新的狀態和字段來支持搶占。

基于信號的搶占式調度的工作原理大致如下:

程序啟動時,在runtime.sighandler函數中注冊一個 SIGURG 信號的處理函數runtime.doSigPreempt

在觸發垃圾回收的棧掃描時會調用函數 runtime.suspendG 掛起 Goroutine,此時會執行下面的邏輯:

將處于運行狀態(_Grunning)的 Goroutine 標記成可以被搶占,即將 Goroutine 的字段 preemptStop 設置成 true;

調用 runtime.preemptM函數, 它可以通過 SIGURG 信號向線程發送搶占請求觸發搶占;

runtime.preemptM 會調用 runtime.signalM 向線程發送信號 SIGURG;

操作系統收到信號后會中斷正在運行的線程并執行預先在第 1 步注冊的信號處理函數 runtime.doSigPreempt;

runtime.doSigPreempt 函數會處理搶占信號,獲取當前的 SP 和 PC 寄存器并調用 runtime.sigctxt.pushCall;

runtime.sigctxt.pushCall 會修改寄存器并在程序回到用戶態時執行 runtime.asyncPreempt;

匯編指令 runtime.asyncPreempt 會調用運行時函數 runtime.asyncPreempt2;

runtime.asyncPreempt2 會調用 runtime.preemptPark;

runtime.preemptPark會修改當前 Goroutine 的狀態到_Gpreempted并調用runtime.schedule讓當前函數陷入休眠并讓出線程,調度器會選擇其它的 Goroutine 繼續執行

_Gpreempted狀態表示當前 groutine 由于搶占而被阻塞,沒有執行用戶代碼并且不在運行隊列上,等待喚醒

在上面的選擇 SIGURG 作為觸發異步搶占的信號:

該信號需要被調試器透傳;

該信號不會被內部的 libc 庫使用并攔截;

該信號可以隨意出現并且不觸發任何后果;

需要處理多個平臺上的不同信號;

垃圾回收過程中需要暫停整個程序(Stop the world,STW),有時候可能需要幾分鐘的時間,這會導致整個程序無法工作。所以 STW 和棧掃描是一個可以搶占的安全點(Safe-points), Go 語言在這里先加入搶占功能?;谛盘柕膿屨际秸{度只解決了垃圾回收和棧掃描時存在的問題,它到目前為止沒有解決全部問題。

go func() 調度流程

基于上面的模型,當我們使用 go func()創建一個新的 goroutine 的時候,其調度流程如下:

a8658076-4972-11ed-a3b6-dac502259ad0.jpg

通過 go func ()來創建一個 goroutine;

有兩個存儲 G 的隊列,一個是局部調度器 P 的本地隊列、一個是全局 G 隊列。新創建的 G 會先保存在 P 的本地隊列中,如果 P 的本地隊列已經滿了就會保存在全局的隊列中;

G 只能運行在 M 中,一個 M 必須持有一個 P,M 與 P 是 1:1 的關系。M 會從 P 的本地隊列彈出一個可執行狀態的 G 來執行,如果 P 的本地隊列為空,就會想其他的 MP 組合偷取一個可執行的 G 來執行;

一個 M 調度 G 執行的過程是一個循環機制;

當 M 執行某一個 G 時候如果發生了 syscall 或則其余阻塞操作,M 會阻塞,如果當前有一些 G 在執行,runtime 會把這個線程 M 從 P 中摘除 (detach),然后再創建一個新的操作系統的線程 (如果有空閑的線程可用就復用空閑線程) 來服務于這個 P;

當 M 系統調用結束時候,這個 G 會嘗試獲取一個空閑的 P 執行,并放入到這個 P 的本地隊列。如果獲取不到 P,那么這個線程 M 變成休眠狀態, 加入到空閑線程中,然后這個 G 會被放入全局隊列中。

Goroutine 生命周期

a884505a-4972-11ed-a3b6-dac502259ad0.png

在這里有一個線程和一個 groutine 比較特殊,那就是 M0 和 G0:

M0:M0 是啟動程序后的編號為 0 的主線程,這個 M 對應的實例會在全局變量 runtime.m0 中,不需要在 heap 上分配,M0 負責執行初始化操作和啟動第一個 G, 在之后 M0 就和其他的 M 一樣了。

G0 :G0 是每次啟動一個 M 都會第一個創建的 gourtine,G0 僅用于負責調度的 G,G0 不指向任何可執行的函數,每個 M 都會有一個自己的 G0。在調度或系統調用時會使用 G0 的??臻g,全局變量的 G0 是 M0 的 G0。

對于下面的簡單代碼:

package main


import "fmt"


// main.main
func main() {
   fmt.Println("Hello scheduler")
}

其運行時所經歷的過程跟上面的生命周期相對應:

runtime 創建最初的線程 m0 和 goroutine g0,并把 2 者關聯。

調度器初始化:初始化 m0、棧、垃圾回收,以及創建和初始化由 GOMAXPROCS 個 P 構成的 P 列表。

示例代碼中的 main 函數是 main.main,runtime 中也有 1 個 main 函數——runtime.main,代碼經過編譯后,runtime.main會調用 main.main,程序啟動時會為 runtime.main 創建 goroutine,稱它為main goroutine,然后把 main goroutine 加入到P的本地隊列。

啟動 m0,m0 已經綁定了 P,會從 P 的本地隊列獲取 G,獲取到 main goroutine。

G 擁有棧,M 根據 G 中的棧信息和調度信息設置運行環境

M 運行 G

G 退出,再次回到 M 獲取可運行的 G,這樣重復下去,直到 main.main 退出,runtime.main執行 Defer 和 Panic 處理,或調用 runtime.exit 退出程序。

調度器的生命周期幾乎占滿了一個Go程序的一生,runtime.main 的 goroutine 執行之前都是為調度器做準備工作,runtime.main 的 goroutine 運行,才是調度器的真正開始,直到 runtime.main 結束而結束。

Goroutine 調度器場景分析

場景一

p1 擁有 g1,m1 獲取 p1 后開始運行g1,g1 使用 go func() 創建了 g2,為了局部性 g2 優先加入到 p1 的本地隊列:

a8a9fe68-4972-11ed-a3b6-dac502259ad0.png

場景二

g1運行完成后(函數:goexit),m 上運行的 goroutine 切換為 g0,g0 負責調度時協程的切換(函數:schedule)。

從 p1 的本地隊列取 g2,從 g0 切換到 g2,并開始運行 g2 (函數:execute)。實現了線程 m1 的復用。

a8d05ebe-4972-11ed-a3b6-dac502259ad0.png

場景三

假設每個 p 的本地隊列只能存 4 個 g。g2 要創建 6 個 g,前 4 個g(g3, g4, g5, g6)已經加入 p1 的本地隊列,p1 本地隊列滿了。

g2 在創建 g7 的時候,發現 p1 的本地隊列已滿,需要執行負載均衡,把 p1 中本地隊列中前一半的 g,還有新創建的 g 轉移到全局隊列

實現中并不一定是新的 g,如果 g 是 g2 之后就執行的,會被保存在本地隊列,利用某個老的 g 替換新 g 加入全局隊列),這些 g 被轉移到全局隊列時,會被打亂順序。

所以 g3,g4,g7 被轉移到全局隊列。

a9120292-4972-11ed-a3b6-dac502259ad0.png

藍色長方形代表全局隊列。

如果此時 G2 創建 G8 時,P1 的本地隊列未滿,所以 G8 會被加入到 P1 的本地隊列:

a947ba22-4972-11ed-a3b6-dac502259ad0.png

場景四

在創建 g 時,運行的 g 會嘗試喚醒其他空閑的 p 和 m 組合去執行。假定 g2 喚醒了 m2,m2 綁定了 p2,并運行 g0,但 p2 本地隊列沒有 g,m2 此時為自旋線程(沒有 G 但為運行狀態的線程,不斷尋找 g)。

a9696fc8-4972-11ed-a3b6-dac502259ad0.png

m2 接下來會嘗試從全局隊列 (GQ) 取一批 g 放到 p2 的本地隊列(函數:findrunnable)。m2 從全局隊列取的 g 數量符合下面的公式:

n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))

公式的含義是,至少從全局隊列取 1 個 g,但每次不要從全局隊列移動太多的 g 到 p 本地隊列,給其他 p 留點。這是從全局隊列到 P 本地隊列的負載均衡。

假定場景中一共有 4 個 P(GOMAXPROCS=4),所以 m2 只從能從全局隊列取 1 個 g(即 g3)移動 p2 本地隊列,然后完成從 g0 到 g3 的切換,運行 g3:

a97dc752-4972-11ed-a3b6-dac502259ad0.png

場景五

假設 g2 一直在 m1上運行,經過 2 輪后,m2 已經把 g7、g4 也挪到了p2的本地隊列并完成運行,全局隊列和 p2 的本地隊列都空了,如下圖左邊所示。

全局隊列已經沒有 g,那 m 就要執行 work stealing:從其他有 g 的 p 哪里偷取一半 g 過來,放到自己的 P 本地隊列。p2 從 p1 的本地隊列尾部取一半的 g,本例中一半則只有 1 個 g8,放到 p2 的本地隊列,情況如下圖右邊:

場景六

p1 本地隊列 g5、g6 已經被其他 m 偷走并運行完成,當前 m1 和 m2 分別在運行 g2 和 g8,m3 和 m4 沒有goroutine 可以運行,m3 和 m4 處于自旋狀態,它們不斷尋找 goroutine。

這里有一個問題,為什么要讓 m3 和 m4 自旋?自旋本質是在運行,線程在運行卻沒有執行 g,就變成了浪費CPU,銷毀線程可以節約CPU資源不是更好嗎?實際上,創建和銷毀CPU都是浪費時間的,我們希望當有新 goroutine 創建時,立刻能有 m 運行它,如果銷毀再新建就增加了時延,降低了效率。當然也考慮了過多的自旋線程是浪費 CPU,所以系統中最多有 GOMAXPROCS 個自旋的線程,多余的沒事做的線程會讓他們休眠(函數:notesleep() 實現了這個思路)。

場景七

假定當前除了 m3 和 m4 為自旋線程,還有 m5 和 m6 為自旋線程,g8 創建了 g9,g9 會放入本地隊列。加入此時g8 進行了阻塞的系統調用,m2 和 p2 立即解綁,p2 會執行以下判斷:如果 p2 本地隊列有 g、全局隊列有 g 或有空閑的 m,p2 都會立馬喚醒1個 m 和它綁定,否則 p2 則會加入到空閑 P 列表,等待 m 來獲取可用的 p。本場景中,p2 本地隊列有 g,可以和其他自旋線程 m5 綁定。

a9901c72-4972-11ed-a3b6-dac502259ad0.png

場景八

假設 g8 創建了 g9,假如 g8 進行了非阻塞系統調用(CGO會是這種方式,見cgocall()),m2 和 p2 會解綁,但 m2 會記住 p,然后 g8 和 m2 進入系統調用狀態。當 g8 和 m2 退出系統調用時,會嘗試獲取 p2,如果無法獲取,則獲取空閑的 p,如果依然沒有,g8 會被記為可運行狀態,并加入到全局隊列。

a9a873e4-4972-11ed-a3b6-dac502259ad0.png

場景九

前面說過,Go 調度在 go1.12 實現了搶占,應該更精確的稱為基于協作的請求式搶占,那是因為 go 調度器的搶占和 OS 的線程搶占比起來很柔和,不暴力,不會說線程時間片到了,或者更高優先級的任務到了,執行搶占調度。go 的搶占調度柔和到只給 goroutine 發送 1 個搶占請求,至于 goroutine 何時停下來,那就管不到了。搶占請求需要滿足2個條件中的1個:

G 進行系統調用超過 20us

G 運行超過 10ms。調度器在啟動的時候會啟動一個單獨的線程 sysmon,它負責所有的監控工作,其中 1 項就是搶占,發現滿足搶占條件的 G 時,就發出搶占請求。

狀態匯總

從上面的場景中可以總結各個模型的狀態:

G狀態

G的主要幾種狀態:

狀態 描述
_Gidle 剛剛被分配并且還沒有被初始化,值為0,為創建goroutine后的默認值
_Grunnable 沒有執行代碼,沒有棧的所有權,存儲在運行隊列中,可能在某個P的本地隊列或全局隊列中
_Grunning 正在執行代碼的goroutine,擁有棧的所有權
_Gsyscall 正在執行系統調用,擁有棧的所有權,沒有執行用戶代碼,被賦予了內核線程 M 但是不在運行隊列上
_Gwaiting 由于運行時而被阻塞,沒有執行用戶代碼并且不在運行隊列上,但是可能存在于 Channel 的等待隊列上
_Gdead 當前goroutine未被使用,沒有執行代碼,可能有分配的棧,分布在空閑列表 gFree,可能是一個剛剛初始化的 goroutine,也可能是執行了 goexit 退出的 goroutine
_Gcopystack 棧正在被拷貝,沒有執行代碼,不在運行隊列上
_Gpreempted 由于搶占而被阻塞,沒有執行用戶代碼并且不在運行隊列上,等待喚醒
_Gscan GC 正在掃描??臻g,沒有執行代碼,可以與其他狀態同時存在

P 狀態

狀態 描述
_Pidle 處理器沒有運行用戶代碼或者調度器,被空閑隊列或者改變其狀態的結構持有,運行隊列為空
_Prunning 被線程 M 持有,并且正在執行用戶代碼或者調度器
_Psyscall 沒有執行用戶代碼,當前線程陷入系統調用
_Pgcstop 被線程 M 持有,當前處理器由于垃圾回收被停止
_Pdead 當前處理器已經不被使用

M 狀態

自旋線程:處于運行狀態但是沒有可執行 goroutine 的線程,數量最多為 GOMAXPROC,若是數量大于 GOMAXPROC 就會進入休眠。

非自旋線程:處于運行狀態有可執行 goroutine 的線程。

調度器設計

從上面的流程可以總結出 goroutine 調度器的一些設計思路:

調度器設計的兩大思想

復用線程:協程本身就是運行在一組線程之上,所以不需要頻繁的創建、銷毀線程,而是對線程進行復用。在調度器中復用線程還有2個體現:

work stealing,當本線程無可運行的 G 時,嘗試從其他線程綁定的 P 偷取 G,而不是銷毀線程。

hand off,當本線程因為 G 進行系統調用阻塞時,線程釋放綁定的 P,把 P 轉移給其他空閑的線程執行。

利用并行:GOMAXPROCS 設置 P 的數量,當 GOMAXPROCS 大于 1 時,就最多有 GOMAXPROCS 個線程處于運行狀態,這些線程可能分布在多個 CPU 核上同時運行,使得并發利用并行。另外,GOMAXPROCS 也限制了并發的程度,比如 GOMAXPROCS = 核數/2,則最多利用了一半的 CPU 核進行并行。

調度器設計的兩小策略

搶占:

在 coroutine 中要等待一個協程主動讓出 CPU 才執行下一個協程,在 Go 中,一個 goroutine 最多占用CPU 10ms,防止其他 goroutine 被餓死,這就是 goroutine 不同于 coroutine 的一個地方。

全局G隊列:

在新的調度器中依然有全局 G 隊列,但功能已經被弱化了,當 M 執行 work stealing 從其他 P 偷不到 G 時,它可以從全局 G 隊列獲取 G。

GPM 可視化調試

有 2 種方式可以查看一個程序 GPM 的數據:

go tool trace

trace 記錄了運行時的信息,能提供可視化的Web頁面。

簡單測試代碼:main 函數創建 trace,trace 會運行在單獨的 goroutine 中,然后 main 打印 “Hello trace” 退出。

func main() {
    // 創建trace文件
    f, err := os.Create("trace.out")
    if err != nil {
        panic(err)
    }
    defer f.Close()


    // 啟動trace goroutine
    err = trace.Start(f)
    if err != nil {
        panic(err)
    }
    defer trace.Stop()


    // main
    fmt.Println("Hello trace")
}

運行程序和運行trace:

$ go run trace.go 
Hello World

會得到一個 trace.out 文件,然后可以用一個工具打開,來分析這個文件:


$ go tool trace trace.out 
2020/12/07 23:09:33 Parsing trace...
2020/12/07 23:09:33 Splitting trace...
2020/12/0723:09:33Openingbrowser.Traceviewerislisteningonhttp://127.0.0.1:56469

接下來通過瀏覽器打開 http://127.0.0.1:33479 網址,點擊 view trace 能夠
看見可視化的調度流程:

aa9a8b70-4972-11ed-a3b6-dac502259ad0.jpg

aab29184-4972-11ed-a3b6-dac502259ad0.png

g 信息

點擊 Goroutines 那一行的數據條,會看到一些詳細的信息:

aae11d6a-4972-11ed-a3b6-dac502259ad0.jpg

上面表示一共有兩個 G 在程序中,一個是特殊的 G0,是每個 M 必須有的一個初始化的 G。其中 G1 就是 main goroutine (執行 main 函數的協程),在一段時間內處于可運行和運行的狀態。

m 信息

點擊 Threads 那一行可視化的數據條,會看到一些詳細的信息:

ab15ac6a-4972-11ed-a3b6-dac502259ad0.jpg

這里一共有兩個 M 在程序中,一個是特殊的 M0,用于初始化使用。

p 信息

ab3b7832-4972-11ed-a3b6-dac502259ad0.jpg

G1 中調用了 main.main,創建了 trace goroutine g6。G1 運行在 P0 上,G6運行在 P1 上。

這里有三個 P。

在看看上面的 M 信息:

ab54a064-4972-11ed-a3b6-dac502259ad0.jpg

可以看到確實 G6 在 P1 上被運行之前,確實在 Threads 行多了一個 M 的數據,點擊查看如下:

ab735b9e-4972-11ed-a3b6-dac502259ad0.jpg

多了一個 M2 應該就是 P1 為了執行 G6 而動態創建的 M2。

Debug trace

示例代碼:

// main.main
func main() {
    for i := 0; i < 5; i++ {
        time.Sleep(time.Second)
        fmt.Println("Hello scheduler")
    }
}

編譯后通過 Debug 方式運行,運行過程會打印trace:

? go build .
? GODEBUG=schedtrace=1000 ./one_routine2

結果:

SCHED 0ms: gomaxprocs=4 idleprocs=2 threads=3 spinningthreads=1 idlethreads=0 runqueue=0 [1 0 0 0]
Hello scheduler
SCHED 1003ms: gomaxprocs=4 idleprocs=4 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0]
Hello scheduler
SCHED 2007ms: gomaxprocs=4 idleprocs=4 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0]
Hello scheduler
SCHED 3010ms: gomaxprocs=4 idleprocs=4 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0]
Hello scheduler
SCHED 4013ms: gomaxprocs=4 idleprocs=4 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0]
Hello scheduler

各個字段的含義如下:

SCHED:調試信息輸出標志字符串,代表本行是 goroutine 調度器的輸出;

0ms:即從程序啟動到輸出這行日志的時間;

gomaxprocs: P的數量,本例有 4 個P;

idleprocs: 處于 idle (空閑)狀態的 P 的數量;通過 gomaxprocs 和 idleprocs 的差值,就可以知道執行 go 代碼的 P 的數量;

threads: os threads/M 的數量,包含 scheduler 使用的 m 數量,加上 runtime 自用的類似 sysmon 這樣的 thread 的數量;

spinningthreads: 處于自旋狀態的 os thread 數量;

idlethread: 處于 idle 狀態的 os thread 的數量;

runqueue=0:Scheduler 全局隊列中 G 的數量;[0 0 0 0]: 分別為 4 個 P 的 local queue 中的 G 的數量。

看第一行,含義是:剛啟動時創建了 4 個P,其中 2 個空閑的 P,共創建 3 個M,其中 1 個 M 處于自旋,沒有 M 處于空閑,第一個 P 的本地隊列有一個 G。

另外,可以加上 scheddetail=1 可以打印更詳細的 trace 信息。

命令:

? GODEBUG=schedtrace=1000,scheddetail=1 ./one_routine2

審核編輯:湯梓紅

聲明:本文內容及配圖由入駐作者撰寫或者入駐合作網站授權轉載。文章觀點僅代表作者本人,不代表電子發燒友網立場。文章及其配圖僅供工程師學習之用,如有內容侵權或者其他違規問題,請聯系本站處理。 舉報投訴
  • Linux
    +關注

    關注

    87

    文章

    11022

    瀏覽量

    207059
  • 調度器
    +關注

    關注

    0

    文章

    96

    瀏覽量

    5175

原文標題:goroutine 調度器原理

文章出處:【微信號:magedu-Linux,微信公眾號:馬哥Linux運維】歡迎添加關注!文章轉載請注明出處。

收藏 人收藏

    評論

    相關推薦

    中斷、切換、調度概念關系不太明白

    切換”、“禁止中斷級切換”、“禁止任務調度”這幾個概念的對應關系。2.任務級切換是不是需要用到軟中斷?希望知道的能解答一下?;蛘吣懈到y的文章,可以分享下。
    發表于 04-28 09:56

    多頻超寬頻天線場景應用

    時代天線部署難題,多頻超寬頻天線成為運營商的最佳選擇。多頻超寬頻天線,滿足TDD/FDD各種場景的混合組網方式,一根天線支持多個頻段,解決天面空間問題,同時預留可能增加的頻段,滿足未來網絡演進,有效保護
    發表于 06-12 07:22

    PTN容量評估助力LTE演進

    的重中之重,其主要的應用場景如下。LTE業務開展場景:LTE業務商用后,對網絡帶寬的消耗將會很大。因此,不管是LTE業務開展之前,還是開展過程中,運營商都需要對現網的帶寬占用進行分析和把握。故障倒換
    發表于 06-17 06:09

    【HarmonyOS】鴻蒙內核源碼分析(調度機制篇)

    源于生活,歸于生活,大家對程序的理解就是要用生活中的場景去打比方,更好的理解概念。那在內核的調度層面,咱們就說task, task是內核調度的單元,
    發表于 10-14 14:00

    鴻蒙內核源碼分析(調度機制篇):Task是如何被調度執行的

    本文分析任務調度機制源碼 詳見:代碼庫建議先閱讀閱讀之前建議先讀本系列其他文章,進入鴻蒙系統源碼分析(總目錄),以便對本文任務調度機制的理解。為什么學一個東西要學那么多的
    發表于 11-23 10:53

    小容量OLT應用場景分析

    是2U高的緊湊型OLT形態。盒式OLT一般是固定式設備,線卡不可插拔,成本低;而2U高OLT一般具備電信級的保護,具備良好的電源、風扇、主控板冗余等能力?! 《?、小容量OLT應用場景分析  下面將按照
    發表于 12-03 14:29

    Linux2.4和Linux2.6的調度對比分析,Linux2.6對調度的改進有哪些方面?

    Linux2.4和Linux2.6的調度對比分析,Linux2.6對調度的改進有哪些方面?Linux2.4
    發表于 04-27 06:42

    編譯優化的靜態調度介紹

    ,使用物理寄存替換虛擬寄存,由于物理寄存數量有限,寄存壓力增大,可能產生寄存spill場景
    發表于 03-17 17:07

    VxWorks實時內核調度的研究分析

    VxWorks實時內核調度的研究分析論述了0S中調度概念、類型、調度隊列模型,并著重對VxWorks實時內核進行了
    發表于 12-16 14:07 ?13次下載

    Vx Works實時內核調度的研究分析

    論述了OS 中調度概念、類型、調度隊列模型,并著重對VxWorks 實時內核進行了分析。關鍵詞:嵌入式實時操作系統(RTOS) ;VxWorks ;
    發表于 03-25 10:36 ?33次下載

    VxWorks實時內核調度的研究分析

    論述了0S中調度概念、類型、調度隊列模型,并著重對VxWorks實時內核進行了分析。
    發表于 11-27 16:22 ?16次下載

    CAN調度理論與實踐分析

    CAN調度理論與實踐分析 CAN總線中消息能否按時送達是事關系統安全等問題的重要指標,它要通過調度分析加以驗證。本文介紹CAN
    發表于 03-29 15:11 ?595次閱讀
    CAN<b class='flag-5'>調度</b>理論與實踐<b class='flag-5'>分析</b>

    CDMA核心網向LTE演進分析

    CDMA核心網向LTE演進分析
    發表于 01-14 11:23 ?29次下載

    基于形式概念分析的圖像場景語義標注模型

    為生成有效表示圖像場景語義的視覺詞典,提高場景語義標注性能,提出一種基于形式概念分析( FCA)的圖像場景語義標注模型。該方法首先將訓練圖像
    發表于 01-12 15:49 ?1次下載
    基于形式<b class='flag-5'>概念</b><b class='flag-5'>分析</b>的圖像<b class='flag-5'>場景</b>語義標注模型

    Linux進程調度時機概念分析

    Linux在眾多進程中是怎么進行調度的,這個牽涉到Linux進程調度時機的概念,由Linux內核中Schedule()的函數來決定是否要進行進程的切換,如果要切換的話,切換到哪個進程等等。
    的頭像 發表于 01-23 17:14 ?2582次閱讀
    Linux進程<b class='flag-5'>調度</b>時機<b class='flag-5'>概念</b><b class='flag-5'>分析</b>
    亚洲欧美日韩精品久久_久久精品AⅤ无码中文_日本中文字幕有码在线播放_亚洲视频高清不卡在线观看
    <acronym id="s8ci2"><small id="s8ci2"></small></acronym>
    <rt id="s8ci2"></rt><rt id="s8ci2"><optgroup id="s8ci2"></optgroup></rt>
    <acronym id="s8ci2"></acronym>
    <acronym id="s8ci2"><center id="s8ci2"></center></acronym>