一、調(diào)度器的由來
調(diào)度本身是指操作系統(tǒng)中為每個任務(wù)分配其所需資源的方法忌怎。
在操作系充中杀狡,線程是任務(wù)執(zhí)行的最小單位蒙畴,是系統(tǒng)調(diào)度的基本單元。
雖然線程比進程輕量捣卤,但是在調(diào)度時也有比較大的額外開銷忍抽,每個線程都會占用幾 M 的內(nèi)存八孝,上下文切換時也會消耗幾微秒的時間董朝,這些都是高并發(fā)的阻礙。
Go 語言的誕生有一個很重要的目的就是并發(fā)編程干跛,所以開發(fā)者為Go語言設(shè)計了一個調(diào)度器來解決這些問題子姜。
Go 的調(diào)度器通過使用與 CPU 數(shù)量相等的線程減少線程頻繁切換的內(nèi)存和時間開銷,同時在每一個線程上執(zhí)行額外開銷更低的協(xié)程來降低資源占用,完成更高并發(fā)哥捕。
1.1 Go 調(diào)度器的歷史
- 單線程調(diào)度器
最初的調(diào)度器極其簡陋牧抽,只有 40 多行代碼,程序只能存在一個活躍線程遥赚,由 (goroutine) - (thread)組成扬舒。
- 多線程調(diào)度器
加入了多線程后,可以運行多線程任務(wù)了凫佛,但是存在嚴(yán)重的同步競爭問題讲坎。
- 任務(wù)竊取調(diào)度器
引入了處理器 (processor),構(gòu)成了 GMP 模型愧薛。 是調(diào)度器的中心晨炕,任何線程想要運行 ,都必須服務(wù) 的安排毫炉,由 完成任務(wù)竊取瓮栗。如果 發(fā)生阻塞, 并不會主動讓出線程瞄勾,導(dǎo)致其他 饑餓费奸。
另外,這一版的調(diào)度器的垃圾回收機制使用了比較笨重的 (stop the world) 機制进陡,在回收垃圾時货邓,會暫停程序很長時間。
- 搶占式調(diào)度器(正在使用)
搶占式調(diào)度器也發(fā)展了兩個階段:
- 基于協(xié)作的搶占式調(diào)度器(1.12 ~ 1.13)四濒。編譯器在函數(shù)調(diào)用時檢查當(dāng)前goroutine是否發(fā)起搶占請求换况。也有 的問題。
- 基于信號的搶占式調(diào)度器(1.14 至今)盗蟆。垃圾回收在掃描棧時會觸發(fā)搶占調(diào)度戈二,但搶占的點不夠多,不能覆蓋全部的邊緣情況喳资。
- 非均勻存儲訪問調(diào)度器(提案)
對運行時的各種資源進行分區(qū)觉吭,但實現(xiàn)非常復(fù)雜,只停留在理論階段仆邓。
1.2 單線程調(diào)度器
最初的調(diào)度器只包括 和 兩種結(jié)構(gòu)鲜滩,全局只有一個線程,所以系統(tǒng)中只能一個任務(wù)一個任務(wù)地執(zhí)行节值,一旦發(fā)生阻塞徙硅,整個后續(xù)任務(wù)都無法執(zhí)行。
單進程調(diào)度器的意義就是建立了 搞疗、 結(jié)構(gòu)嗓蘑,為后續(xù)的調(diào)度器發(fā)展打下了基礎(chǔ)。
1.3 多線程調(diào)度器
在 1.0 版本中,Go 更換了多線程調(diào)度器桩皿,與單線程調(diào)度器相比豌汇,可以說是質(zhì)的飛躍。
多線程調(diào)度器的整體邏輯與單線程調(diào)度器沒有太大的區(qū)別泄隔,因為引入了多線程拒贱,所以 Go 使用環(huán)境變量GOMAXPROCS
幫助我們靈活控制程序中的最大處理器數(shù),即活躍線程數(shù)佛嬉。
多線程調(diào)度器的主要問題是調(diào)度時的鎖競爭會嚴(yán)重浪費資源柜思,有14%的時間浪費在此。
主要問題有:
- 調(diào)度器和鎖是全局資源巷燥,所有的調(diào)度狀態(tài)都是中心化存儲的赡盘,鎖競爭嚴(yán)重
- 線程需要經(jīng)常互相傳遞可運行的 Goroutine缰揪,引入了大量的延遲
- 每個線程都需要處理內(nèi)存緩存陨享,導(dǎo)致大量的內(nèi)存占用并影響數(shù)據(jù)局部性
- 系統(tǒng)調(diào)用頻繁阻塞和解除阻塞正在運行的線程,增加額外開銷
1.4 任務(wù)竊取調(diào)度器
基于任務(wù)竊取的Go語言調(diào)度器使用了沿用至今的 GMP 模型钝腺,主要改進了兩點:
- 在當(dāng)前的 - 模型中引入了處理器 抛姑,增加中間層
- 在處理器 的基礎(chǔ)上實現(xiàn)基于工作竊取的調(diào)度器
當(dāng)前處理器P的本地隊列中如果沒有 ,就會觸發(fā)工作竊取艳狐,從其他的 的隊列中隨機獲取一些 定硝。調(diào)度器在調(diào)度時會將P的本地隊列的隊列頭的 取出來,放到 上執(zhí)行毫目。
1.4.1 G
Goroutine/G 是 Go 語言調(diào)度器中待執(zhí)行的任務(wù)蔬啡,它在運行時調(diào)度器的地位與線程在操作系統(tǒng)中差不多,但它占用更小的空間镀虐,也極大地降低了上下文切換的開銷箱蟆。
只存在于 Go 語言的運行時,它只是 Go 語言在用戶態(tài)提供的線程刮便,作為一種粒度更細(xì)的資源調(diào)度單元空猜,能夠在高并發(fā)的場景下更高效地利用機器的資源。
1.4.2 M
M的數(shù)量最多可以創(chuàng)建 10000 個恨旱,但其中大多數(shù)都不會執(zhí)行用戶代碼而是陷入系統(tǒng)調(diào)用辈毯,最多也只有GOMAXPROCS
個線程能夠正常運行。
默認(rèn)情況下搜贤,程序運行時會將GOMAXPROCS
設(shè)置為當(dāng)前機器的核數(shù)谆沃。且大多數(shù)情況下,我們都會使用 Go 的默認(rèn)設(shè)置入客,也就是線程數(shù)等于 CPU 數(shù)管毙。因為默認(rèn)的設(shè)置不會頻繁地觸發(fā)操作系統(tǒng)的線程調(diào)度和上下文切換,所有的調(diào)度都發(fā)生在用戶態(tài)桌硫,由 Go 語言調(diào)度器觸發(fā)夭咬,能減少很多額外開銷。
每個M都有兩個 铆隘,G0
和curg
卓舵。G0
是持有調(diào)度棧的 ,curg
是當(dāng)前線程上運行的用戶 膀钠,這是操作系統(tǒng)唯一關(guān)心的兩個 掏湾。
G0
深度參與 的調(diào)度,包括 的創(chuàng)建肿嘲、大內(nèi)存分配和 CGO 函數(shù)的執(zhí)行融击。
1.4.3 P
Processor/P 是 和 的中間層,提供 需要的上下文環(huán)境雳窟,也負(fù)責(zé)調(diào)度 的等待隊列和全局等待隊列尊浪。
通過 的調(diào)度,每一個 都能夠執(zhí)行多個 封救。當(dāng) 進行 I/O 操作時拇涤, 會讓這個 讓出計算資源,提高 的利用率誉结。
調(diào)度器會在啟動時創(chuàng)建GOMAXPROCS
個處理器鹅士,所以 的數(shù)量一定會等于GOMAXPROCS
,這些 會綁定到不同的 上惩坑。
1.5 搶占式調(diào)度器
因為竊取式調(diào)度器存在饑餓和 耗時過長的問題掉盅,所以后續(xù)又推出了基于協(xié)作的搶占式調(diào)度器。
1.5.1 基于協(xié)作的搶占式調(diào)度器
- 編繹器在調(diào)用函數(shù)前插入搶占函數(shù)(檢查是否需要更多的棧)
- 運行時會在 GC 的 STW 以舒、系統(tǒng)監(jiān)控中發(fā)現(xiàn)運行時長超過 10ms 的 怔接,通用一個字段
StackPreempt
標(biāo)記需要搶占的 - 在發(fā)生函數(shù)調(diào)用時,可能會執(zhí)行編繹器插入的搶占函數(shù)稀轨,搶占函數(shù)會調(diào)用創(chuàng)建新棧函數(shù)檢查 的搶占字段
- 如果 被標(biāo)記為可搶占扼脐,就會觸發(fā)搶占讓出當(dāng)前線程
這種方式雖然增加了運行時的復(fù)雜度,但實現(xiàn)相對簡單奋刽,沒有帶來額外開銷瓦侮,因為其是通過編繹器插入函數(shù)并通過調(diào)度函數(shù)作為入口觸發(fā)搶占,所以是協(xié)作式的搶占佣谐。
1.5.2 基于信號的搶占式調(diào)度器
協(xié)作搶占在一定程度上解決了竊取調(diào)度中的問題肚吏,但卻產(chǎn)生了嚴(yán)重的邊緣問題,非常影響開發(fā)體驗[1]狭魂。
為了改善這些問題罚攀,在 1.14 版本中實現(xiàn)了非協(xié)作式的搶占式調(diào)度党觅,為 增加新的狀態(tài)和字段并改變原邏輯實現(xiàn)搶占。
- 啟動程序時斋泄,注冊信號處理函數(shù)
- 在 GC 掃描棧時將 標(biāo)記為可搶占杯瞻,并調(diào)用搶占函數(shù),將 掛起
- 搶占函數(shù)會向線程發(fā)送信號
- 系統(tǒng)接到信號后會中斷正在運行的線程并執(zhí)行預(yù)先注冊的信息處理函數(shù)
- 根據(jù)搶占信號修改當(dāng)前寄存器炫掐,在程序回到用戶態(tài)時執(zhí)行異步搶占
- 將當(dāng)前 標(biāo)記為被搶占后讓當(dāng)前函數(shù)陷入休眠并讓出 魁莉, 選擇其他 繼續(xù)執(zhí)行
二、調(diào)度器運行
2.1 調(diào)度器啟動
程序初始化時會創(chuàng)建初始線程 和主協(xié)程 [2]募胃,并將 綁定到 旗唁。
是啟動程序后的編號為 0 的主線程,這個 對應(yīng)的實例會在全局變量runtime.m0中痹束,不需要在heap上分配检疫, 負(fù)責(zé)執(zhí)行初始化操作和啟動第一個 , 在之后 就和其他的 一樣了祷嘶。
是每次啟動一個M都會第一個創(chuàng)建的gourtine电谣, 僅用于負(fù)責(zé)調(diào)度的 , 不指向任何可執(zhí)行的函數(shù), 每個 都會有一個自己的 抹蚀。在調(diào)度或系統(tǒng)調(diào)用時會使用 的椊宋空間。
系統(tǒng)初始化完畢后調(diào)用調(diào)度器初始化环壤,此時會將maxmcount
設(shè)置為 10000晒来,這是一個 Go 程序能夠創(chuàng)建的最大線程數(shù),雖然可以創(chuàng)建 10000 個線程郑现,但是可以同時運行的 還中由GOMAXPROCS
變量控制湃崩。
從環(huán)境變量中獲取了GOMAXPROCS
后就會更新程序中 的數(shù)量,這時整個程序不會執(zhí)行任何用戶的 接箫, 也會進入鎖定狀態(tài)攒读。
-
全局列隊長度如果小于
GOMAXPROCS
就擴容 - 遍歷 隊列,為隊列中為
nil
的位置使用new
方法創(chuàng)建新的 辛友,并調(diào)用 的初始化函數(shù) - 通過指針將 和隊列中的第一個 綁定到一起
- 釋放不再使用的
- 如果全局隊列長度不等于
GOMAXPROCS
薄扁,則截斷全局隊列使其相等[3] - 之前只判斷全局隊列小于
GOMAXPROCS
的情況,所以這里的不相等废累,就是大于等于GOMAXPROCS
- 將全局隊列中除第一個 之外的所有 設(shè)置為空閑狀態(tài)并加入到全局空閑隊列中
之后調(diào)度器會完成相應(yīng)數(shù)量的 的啟動邓梅,等待用戶創(chuàng)建新的 并為 調(diào)度處理器資源。
2.2 創(chuàng)建
想在啟動一個新的 來執(zhí)行任務(wù)邑滨,需要使用 Go 語言的go
關(guān)鍵字日缨。
編繹器會根據(jù)go
后的函數(shù)和當(dāng)前調(diào)用程序創(chuàng)建新的 結(jié)構(gòu)體并加入 的本地運行隊列或者全局運行隊列中恩急。
當(dāng)本地運行隊列滿時眷昆,會將本地隊列中的一部分 和待加入的 添加到全局運行隊列中锁右。
的本地運行隊列最多可以存儲256個 上荡。
2.3 調(diào)度循環(huán)
調(diào)度器啟動后,初始化 的棧毅待,然后初始化線程并進入調(diào)度循環(huán)尚卫。
2.3.1 獲取
- 為了保證公平,當(dāng)全局運行隊列中有待執(zhí)行的 時恩静,會有一定幾率從全局隊列中獲取 執(zhí)行
- 主要還是從 的本地運行隊列中獲取
- 如果前兩種方法都沒有獲取到 焕毫,會通過下面三個方式進行阻塞查找 :
- 從本地運行隊列蹲坷、全局運行隊列中查找
- 從網(wǎng)絡(luò)輪詢中查找是否有 等待執(zhí)行
- 嘗試從其他隨機的的 中竊取 驶乾,還可能竊取其他 的計時器
所以當(dāng)前函數(shù)一定會返回一個可執(zhí)行的 ,如果實再獲取不到循签,就會阻塞等待级乐。
2.3.2 執(zhí)行
將獲取到的 調(diào)度到當(dāng)前 上執(zhí)行。
當(dāng)前的 執(zhí)行完畢后县匠, 會重新執(zhí)行調(diào)度函數(shù)觸發(fā)新一輪的調(diào)度风科。
Go 語言的運行時調(diào)度是一個循環(huán)的過程,永不返回乞旦。
上面是 正常執(zhí)行的調(diào)度過程贼穆,但實際上, 也可能不正常執(zhí)行兰粉,比如阻塞住故痊,此時調(diào)度器會斷開當(dāng)前 和 的關(guān)系,創(chuàng)建或從休眠隊列中獲取一個新的 重新與 組合玖姑。原 和 會有一個超時時間愕秫,如果此期間沒能執(zhí)行完成,將會釋放 焰络, 或休眠戴甩,或獲取一個空閑的 繼續(xù)工作,繼續(xù)開始新的調(diào)度過程闪彼。
2.4 線程管理
Go 語言的運行時會通過調(diào)度器改變線程的所有權(quán)甜孤,也提供了 runtime.LockOSThread
和 runtime.UnlockOSThread
讓我們有能力綁定 Goroutine 和線程完成一些比較特殊的操作。
Goroutine 應(yīng)該在調(diào)用操作系統(tǒng)服務(wù)或者依賴線程狀態(tài)的非 Go 語言庫時調(diào)用 runtime.LockOSThread
函數(shù)畏腕,例如:C 語言圖形庫等课蔬。
當(dāng) Goroutine 執(zhí)行完特殊操作后,會分離 Goroutine 和線程郊尝。
在多數(shù)情況下二跋,都用不到這一對函數(shù)。
2.4.1 線程生命周期
Go 語言的運行時通過 執(zhí)行 流昏,如果沒有空閑 就會創(chuàng)建新的 扎即。
新創(chuàng)建線程只有主動調(diào)用退出命令或啟動函數(shù)runtime.mstart
返回時主動退出吞获。
由此完成線程從創(chuàng)建到銷毀的整個閉環(huán)。
2.4.2 自旋線程
當(dāng) GMP 組合 的本地隊列中沒有 時谚鄙, 就是自旋線程各拷,自旋線程就會如果[2.3.1](#2.3.1 獲取 )中所說一直阻塞查找 。
為什么要有自旋線程闷营?
自旋本質(zhì)是保持線程運行烤黍,銷毀再創(chuàng)建一個新的線程要消耗大量的CPU資源和時間。
如果創(chuàng)建了一個新的 傻盟,立即能被自旋線程捕獲速蕊,而如果新建線程則不能保持即時性,也就降低了效率娘赴。
但自旋線程數(shù)量也不宜過多规哲,過多的自旋線程同樣也是浪費CPU資源,所以系統(tǒng)中最多有GOMAXPROCS
個自旋線程诽表,通常數(shù)量有GOMAXPROCS
- 正在運行的非自旋線程數(shù)量唉锌,多余的線程會休眠或被銷毀。
三竿奏、讓GMP可視化
3.1 go tool
go tool trace 記錄了運行時的信息袄简,能提供可視化的 web 頁面。
測試代碼:
// trace.go
package main
import (
"os"
"fmt"
"runtime/trace"
)
func main() {
//創(chuàng)建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 World")
}
運行程序:
$ go run trace.go
會得到一個trace.out
文件泛啸,然后可以用 go 內(nèi)置的 trace 打開這個文件:
$ go tool trace -http=0.0.0.0:9999 trace.out
2021/03/28 17:29:24 Parsing trace...
2021/03/28 17:29:24 Splitting trace...
2021/03/28 17:29:24 Opening browser. Trace viewer is listening on http://[::]:9999
訪問http://127.0.0.1:9999/trace或你指定的 host:port 可以查看可視化的調(diào)度過程绿语。
3.1.1 信息
點擊 Goroutines 對應(yīng)的那一條藍(lán)色矩形,下面的窗口會輸入一些詳細(xì)信息
程序運行過程中一共創(chuàng)建了兩個 平痰,其中一個是必須創(chuàng)建的 汞舱。
所以 才是 main goroutine ,從可運行狀態(tài)變?yōu)檎谶\行宗雇,然后程序結(jié)束昂芜。
3.1.2 信息
點擊 Thread 對應(yīng)的紫色矩形:
一共有兩個 ,其中一個是 赔蒲,程序初始化時創(chuàng)建泌神。
3.1.3 信息
中調(diào)用的main.main
,創(chuàng)建了本次使用的trace G
舞虱, 運行在 上欢际, 運行在 上。
3.2 Debug
//trace2.go
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 5; i++ {
time.Sleep(time.Second)
fmt.Println("Hello World")
}
}
需要先編繹:
$ go build trace2.go
運行:
$ GODEBUG=schedtrace=1000 ./trace
SCHED 0ms: gomaxprocs=2 idleprocs=0 threads=5 spinningthreads=1 idlethreads=1 runqueue=0 [0 0]
Hello World
SCHED 1009ms: gomaxprocs=2 idleprocs=2 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0]
Hello World
SCHED 2016ms: gomaxprocs=2 idleprocs=2 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0]
Hello World
SCHED 3022ms: gomaxprocs=2 idleprocs=2 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0]
Hello World
SCHED 4030ms: gomaxprocs=2 idleprocs=2 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0]
Hello World
關(guān)鍵字說明:
-
SCHED
調(diào)度器的縮寫矾兜,標(biāo)志本行輸入是 Goroutine 調(diào)度器的輸出 -
0ms
從程序啟動到輸出這行日志經(jīng)過的時間 -
gomaxprocs
最大活躍線程/ 的數(shù)量损趋,與CPU數(shù)量一致 -
idleprocs
空閑 數(shù)量 -
threads
系統(tǒng)線程數(shù)量 -
spinningthreads
自旋線程數(shù)量 -
idlethreads
空閑線程數(shù)量 -
runqueue
調(diào)度器全局隊列中 的數(shù)量 -
[0 0]
分別為2個 的本地隊列中 的數(shù)量