1 調(diào)度模型
Linux操作系統(tǒng)中的資源調(diào)度是基于進程的蛉抓,同一進程中的線程共享這個進程中的所有資源诬垂,所以linux中的線程本質(zhì)上是一種輕量級進程量九,同樣被操作系統(tǒng)進行統(tǒng)一調(diào)度捆愁。而linux又將線程的實現(xiàn)分為兩種:
用戶級線程
線程切換不需要轉(zhuǎn)換到內(nèi)核空間燕刻,節(jié)省了寶貴的內(nèi)核空間
調(diào)度算法可以是進程專用饿凛,由用戶程序進行指定
用戶級線程實現(xiàn)和操作系統(tǒng)無關(guān)
內(nèi)核級線程
在多處理器上软驰,內(nèi)核可以調(diào)用同一進程中的多個線程同時工作
如果一個進程中的一個線程阻塞了,其他線程仍然可以得到運行
線程的切換代價太大心肪,需要進程進入到內(nèi)核態(tài)并且由內(nèi)核切換
1.1 一對一
該模型實現(xiàn)簡單锭亏,所有用戶線程由系統(tǒng)調(diào)用,導致上下文切換成本高硬鞍,用戶線程的增加會給操作系統(tǒng)內(nèi)核帶來巨大壓力
1.2 一對多
該模型雖然減少了內(nèi)核線程的數(shù)量慧瘤,但是用戶線程無法參與到系統(tǒng)的CPU調(diào)度中,且與固定的內(nèi)核線程綁定固该,對于用一個內(nèi)核線程下的用戶線程等于是串行锅减,一旦一個用戶線程阻塞,其他用戶線程會無法調(diào)度蹬音。
1.3 多對多
改模型中用戶線程和內(nèi)核線程非綁定上煤,應用程序和系統(tǒng)共同進行CPU資源的調(diào)度,解決了之前模型的缺點著淆,但是實現(xiàn)邏輯復雜劫狠。Golang使用的就是基于該模型的調(diào)度方案
2 GPM模型
(G)oroutine
每個 Goroutine 對應一個 G 結(jié)構(gòu)體,G 存儲 Goroutine 的運行堆棧永部、狀態(tài)以及任務(wù)函數(shù)独泞,可重用。G 并非執(zhí)行體苔埋,每個 G 需要綁定到 P 才能被調(diào)度執(zhí)行懦砂。
(P)rocessor
表示邏輯處理器, 對 G 來說组橄,P 相當于 CPU 核荞膘,G 只有綁定到 P(在 P 的 local runq 中)才能被調(diào)度。對 M 來說玉工,P 提供了相關(guān)的執(zhí)行環(huán)境(Context)羽资,如內(nèi)存分配狀態(tài)(mcache),任務(wù)隊列(G)等遵班,P 的數(shù)量決定了系統(tǒng)內(nèi)最大可并行的 G 的數(shù)量(前提:物理 CPU 核數(shù) >= P 的數(shù)量)屠升,P 的數(shù)量由用戶設(shè)置的 GOMAXPROCS 決定,但是不論 GOMAXPROCS 設(shè)置為多大狭郑,P 的數(shù)量最大為 256腹暖。
(M)achine
OS 線程抽象,代表著真正執(zhí)行計算的資源翰萨,在綁定有效的 P 后脏答,進入 schedule 循環(huán);而 schedule 循環(huán)的機制大致是從 Global 隊列、P 的 Local 隊列以及 wait 隊列中獲取 G以蕴,切換到 G 的執(zhí)行棧上并執(zhí)行 G 的函數(shù)糙麦,調(diào)用 goexit 做清理工作并回到 M,如此反復丛肮。M 并不保留 G 狀態(tài),這是 G 可以跨 M 調(diào)度的基礎(chǔ)魄缚,M 的數(shù)量是不定的宝与,由 Go Runtime 調(diào)整,為了防止創(chuàng)建過多 OS 線程導致系統(tǒng)調(diào)度不過來冶匹,目前默認最大限制為 10000 個习劫。
調(diào)度流程
每個 P 維護一個 G 的本地隊列;
當一個 G 被創(chuàng)建出來嚼隘,或者變?yōu)榭蓤?zhí)行狀態(tài)時诽里,就把他放到 P 的本地可執(zhí)行隊列中,如果滿了則放入Global飞蛹;
當一個 G 在 M 里執(zhí)行結(jié)束后谤狡,P 會從隊列中把該 G 取出;如果此時 P 的隊列為空卧檐,即沒有其他 G 可以執(zhí)行墓懂, M 就隨機選擇另外一個 P,從其可執(zhí)行的 G 隊列中取走一半(work stealing)霉囚。
為了避免G中出現(xiàn)系統(tǒng)調(diào)用捕仔、阻塞操作導致P&M被阻塞,Go中還引入了搶占式調(diào)度機制盈罐,每個函數(shù)的入口方法出都會注入一段代碼榜跌,用于判斷runtime此時是否可以進行搶占式調(diào)度,判定可以搶占后會由空閑M去搶占盅粪。
2.2 Goroutine
goroutines 意味著并行(或者可以以并行的方式部署)钓葫,coroutines 一般來說不是這樣的,期運行機制屬于協(xié)作式任務(wù)處理湾揽,coroutines在不需要使用 CPU 時瓤逼,會主動交出 CPU 使用權(quán),主要以并發(fā)方式部署库物。
goroutine用法如下:
//go 關(guān)鍵字放在方法調(diào)用前新建一個 goroutine 并執(zhí)行方法體
go GetThingDone(param1, param2);
//新建一個匿名方法并執(zhí)行
go func(param1, param2) {
}(val1, val2)
//直接新建一個 goroutine 并在 goroutine 中執(zhí)行代碼塊
go {
//do someting...
}
代碼中的go關(guān)鍵字會被編譯器翻譯成newproc操作
G的創(chuàng)建
G結(jié)構(gòu)體會復用霸旗,對可復用的G管理類似于待運行的G管理,也有Local隊列(p.gfree)和Global隊列(sched.gfree)之分戚揭,獲取算法差不多诱告,優(yōu)先從p.gfree中獲取(無鎖操作),否則從sched.gfree中獲取并批量轉(zhuǎn)移一部分(有鎖操作)民晒,源代碼參考src/runtime/proc.go:gfget
函數(shù)精居。
從Goroutine的角度來看锄禽,通過go func()
創(chuàng)建時,會從當前閑置的G隊列取得可復用的G靴姿,如果沒有則通過malg新建一個G沃但,然后:
如果不是gc協(xié)程或者從全局隊列挪進來的G,都會將G添加到當前P的runnext中佛吓,作為下一個執(zhí)行的G宵晚,然后runnext的舊值會被添加到runq隊列中去
否則放到Local隊列runq中(無鎖)
如果以上操作都失敗,則添加到Global隊列sched.runq中(有鎖操作维雇,因此也會順便將當P.runq中一半的G轉(zhuǎn)移到sched.runq)
2.3 Processor
為什么要有P淤刃?
如果沒有P:
- 存在單一的全局 mutex(Sched.Lock)和集中狀態(tài)管理:
** mutex 需要保護所有與 goroutine 相關(guān)的操作(創(chuàng)建、完成吱型、重排等)逸贾,導致鎖競爭嚴重。
- Goroutine 傳遞的問題:
-- goroutine(G)交接(G.nextg):工作者線程(M's)之間會經(jīng)常交接可運行的 goroutine津滞。
-- 上述可能會導致延遲增加和額外的開銷铝侵。每個 M 必須能夠執(zhí)行任何可運行的 G,特別是剛剛創(chuàng)建 G 的 M据沈。
- 每個 M 都需要做內(nèi)存緩存(M.mcache):
-- 會導致資源消耗過大(每個 mcache 可以吸納到 2M 的內(nèi)存緩存和其他緩存)哟沫,數(shù)據(jù)局部性差。
- 頻繁的線程阻塞/解阻塞:
-- 在存在 syscalls 的情況下锌介,線程經(jīng)常被阻塞和解阻塞嗜诀。這增加了很多額外的性能開銷。
有P之后:
每個 P 有自己的本地隊列孔祸,大幅度的減輕了對全局隊列的直接依賴隆敢,所帶來的效果就是鎖競爭的減少。而 GM 模型的性能開銷大頭就是鎖競爭崔慧。
每個 P 相對的平衡上拂蝎,在 GMP 模型中也實現(xiàn)了 Work Stealing 算法,如果 P 的本地隊列為空惶室,則會從全局隊列或其他 P 的本地隊列中竊取可運行的 G 來運行温自,減少空轉(zhuǎn),提高了資源利用率皇钞。
為什么不在M上實現(xiàn)
一般來講悼泌,M 的數(shù)量都會多于 P。像在 Go 中夹界,M 的數(shù)量默認是 10000馆里,P 的默認數(shù)量的 CPU 核數(shù)。另外由于 M 的屬性,也就是如果存在系統(tǒng)阻塞調(diào)用鸠踪,阻塞了 M丙者,又不夠用的情況下,M 會不斷增加营密。
M 不斷增加的話械媒,如果本地隊列掛載在 M 上,那就意味著本地隊列也會隨之增加卵贱。這顯然是不合理的滥沫,因為本地隊列的管理會變得復雜,且 Work Stealing 性能會大幅度下降键俱。
M 被系統(tǒng)調(diào)用阻塞后,我們是期望把他既有未執(zhí)行的任務(wù)分配給其他繼續(xù)運行的世分,而不是一阻塞就導致全部停止编振。
golang中的三種系統(tǒng)線程
主線程:golang程序啟動加載的時候就運行在主線程上,代碼中由一個全局的m0表示
運行sysmon的線程
普通用戶線程臭埋,用來與p綁定踪央,運行g(shù)中的任務(wù)的線程
主線程和運行sysmon都是單實例,單獨一個線程瓢阴。而用戶線程會有很多事例畅蹂,他會根據(jù)調(diào)度器的需求新建,休眠和喚醒荣恐。
2.4 Machine
M和G液斜、P一樣都是復用的,一旦創(chuàng)建叠穆,就不會被銷毀少漆,M的狀態(tài)轉(zhuǎn)化是最簡單,除了M0之外硼被,都是通過newm()函數(shù)創(chuàng)建示损,然后通過mstart,進入調(diào)度循環(huán)嚷硫,之后可以通過stopm進入阻塞狀態(tài)检访,再通過startm喚醒,狀態(tài)圖如下:
為什么M數(shù)量會比P多仔掸?
因為M是golang中真正被操作系統(tǒng)管理并執(zhí)行邏輯的單元脆贵,G和P都是golang內(nèi)部的抽象概念
M承接了golang中所有的調(diào)度邏輯和協(xié)程邏輯,如果一個M由于syscall或其他原因被阻塞嘉汰,這時golang就需要啟動一個新的M去繼續(xù)進行其他G的調(diào)度丹禀,最壞的情況下每個G都會阻塞掉一個P。
runtime包提供了LockOSThread操作,會使得每個G與M一對一綁定
P實質(zhì)上映射的是計算機的物理CPU双泪,G實質(zhì)上是M在golang中的映射
2.5 啟動
通過 Entry point 的調(diào)試持搜,可看到真正的程序入口在 runtime 包中,不同的計算機架構(gòu)指向不同焙矛。例如:
MacOS 在 src/runtime/rt0_darwin_amd64.s
Linux 在 src/runtime/rt0_linux_amd64.s
rt0 代表 runtime0 的縮寫葫盼,指代運行時的創(chuàng)世Runtime
所有的goroutine的調(diào)用函數(shù)都是goexit,這樣當其執(zhí)行結(jié)束時村斟,可以返回到goexit中贫导,繼續(xù)調(diào)度循環(huán),goexit的地址保存在gobuf.pc中蟆盹,g0的棧是在主線程上分配的
2.6 調(diào)度
mstart中綁定好P之后孩灯,M擁有了可分配cache和執(zhí)行隊列,進入核心調(diào)度循環(huán)逾滥,核心調(diào)度從schedule函數(shù)開始峰档,調(diào)度完一次之后會引導重新執(zhí)行schedule,實現(xiàn)循環(huán)調(diào)度:
schedule方法的主要功能是盡可能給M找到可以運行的G寨昙,然后執(zhí)行:
獲取當前調(diào)度的g讥巡,也就是g0,g0在執(zhí)行調(diào)度邏輯舔哪;
如果當前GC需要停止整個世界(STW), 則調(diào)用gcstopm休眠當前的M欢顷;
如果M擁有的P中指定了需要在安全點運行的函數(shù)(P.runSafePointFn), 則運行它;
快速獲取待運行的G, 以下處理如果有一個獲取成功后面就不會繼續(xù)獲茸皆椤:
-- 如果當前GC正在標記階段, 則查找有沒有待運行的GC Worker, GC Worker也是一個G抬驴;
-- 為了公平起見, 每61次調(diào)度從全局運行隊列獲取一次G, (一直從本地獲取可能導致全局運行隊列中的G不被運行);
-- 從P的本地運行隊列中獲取G, 調(diào)用runqget函數(shù)外里。
- 快速獲取失敗時, 調(diào)用findrunnable函數(shù)獲取待運行的G, 會阻塞到獲取成功為止:
-- 如果當前GC需要停止整個世界(STW), 則調(diào)用stopm休眠當前的M怎爵;
-- 如果M擁有的P中指定了需要在安全點運行的函數(shù)(P.runSafePointFn), 則運行它;
-- 如果有析構(gòu)器待運行則使用"運行析構(gòu)器的G"盅蝗;
-- 從P的本地運行隊列中獲取G, 調(diào)用runqget函數(shù)鳖链,如果獲取到就返回;
-- 從全局運行隊列獲取G, 調(diào)用globrunqget函數(shù), 需要上鎖墩莫,獲取到就返回芙委。;
-- 從網(wǎng)絡(luò)事件反應器獲取G, 函數(shù)netpoll會獲取哪些fd可讀可寫或已關(guān)閉, 然后返回等待fd相關(guān)事件的G狂秦;
-- 如果從local 和 global 都獲取不到G, 則執(zhí)行Work Stealing:
--- 調(diào)用runqsteal嘗試從其他P的本地運行隊列盜取一半的G灌侣。
-- 如果還是獲取不到G, 就需要休眠M了, 接下來是休眠的步驟:
--- 再次檢查當前GC是否在標記階段, 在則查找有沒有待運行的GC Worker, GC Worker也是一個G;
--- 再次檢查如果當前GC需要停止整個世界, 或者P指定了需要再安全點運行的函數(shù), 則跳到findrunnable的頂部重試裂问;
--- 再次檢查全局運行隊列中是否有G, 有則獲取并返回侧啼;
--- 釋放M擁有的P, P會變?yōu)榭臻e(_Pidle)狀態(tài)牛柒;
--- 把P添加到"空閑P鏈表"中;
--- 讓M離開自旋狀態(tài), 這里的處理非常重要, 參考上面的"空閑M鏈表"痊乾;
--- 首先減少表示當前自旋中的M的數(shù)量的全局變量nmspinning皮壁;
--- 再次檢查所有P的本地運行隊列, 如果不為空則讓M重新進入自旋狀態(tài), 并跳findrunnable的頂部重試;
--- 再次檢查有沒有待運行的GC Worker, 有則讓M重新進入自旋狀態(tài), 并跳到findrunnable的頂部重試
--- 再次檢查網(wǎng)絡(luò)事件反應器是否有待運行的G, 這里對netpoll的調(diào)用會阻塞, 直到某個fd收到了事件哪审;
--- 如果最終還是獲取不到G, 調(diào)用stopm休眠當前的M蛾魄;
--- 喚醒后跳到findrunnable的頂部重試。
成功獲取到一個待運行的G湿滓;
讓M離開自旋狀態(tài), 調(diào)用resetspinning, 這里的處理和上面的不一樣:
-- 如果當前有空閑的P, 但是無自旋的M(nmspinning等于0), 則喚醒或新建一個M滴须;
-- 上面離開自旋狀態(tài)是為了休眠M, 所以會再次檢查所有隊列然后休眠;
-- 這里離開自選狀態(tài)是為了執(zhí)行G, 所以會檢查是否有空閑的P, 有則表示可以再開新的M執(zhí)行G叽奥。
- 如果G要求回到指定的M(例如上面的runtime.main):
-- 調(diào)用startlockedm函數(shù)把G和P交給該M, 自己進入休眠扔水;
-- 從休眠喚醒后跳到schedule的頂部重試
- 調(diào)用execute函數(shù)在當前M上執(zhí)行G。
在schedule方法中朝氓,m在以下情況會休眠:
當m.lockedg != 0(m有綁定固定執(zhí)行的g)铭污,m會在stoplockedm()解綁p并休眠,等待被綁定的g被其他m調(diào)度的時候來喚醒該m膀篮,直接被綁定的g
sched.gcwaiting != 0(gc STW)m會休眠
findrunnable()中想盡一切辦法都沒有獲取到可執(zhí)行的g的時候,m會休眠
獲取到g的時候岂膳,g綁定了其他的m(gp.lockedm != 0),當前m會解綁p誓竿,休眠,然后喚醒g綁定的m谈截,執(zhí)行該g
當m休眠被喚醒的時候筷屡,并不會從固定的位置開始執(zhí)行,會直接從休眠的位置開始執(zhí)行簸喂。
2.6.1 findrunnable
這里邏輯較為復雜毙死,下面將以代碼中的兩個標簽top和stop將流程分開:
2.6.2 sysmon
系統(tǒng)調(diào)用和搶占調(diào)度都離不開這個函數(shù),sysmon獨立的運行在一個特殊的m上喻鳄,它定期執(zhí)行一次扼倘,每次會做以下事情:
釋放閑置超過5分鐘的span物理內(nèi)存
如果超過2分鐘沒有垃圾回收,強制執(zhí)行
將長時間未處理的netpoll結(jié)果添加到任務(wù)隊列
向長時間運行的G任務(wù)發(fā)出搶占調(diào)度
收回因syscall長時間阻塞的P
2.6.3 搶占式調(diào)度
在Go1.3版本以前除呵,調(diào)度器還不支持搶占式調(diào)度再菊,只能依靠 goroutine 主動讓出 CPU 資源,存在非常嚴重的調(diào)度問題:
單獨的 goroutine 可以一直占用線程運行颜曾,不會切換到其他的 goroutine纠拔,造成饑餓問題
垃圾回收需要暫停整個程序(Stop-the-world,STW)泛豪,如果沒有搶占可能需要等待幾分鐘的時間稠诲,導致整個程序無法工作
在Go1.3版本中侦鹏,當某個goroutine執(zhí)行超過10ms,sysmon會向其發(fā)起搶占調(diào)度請求臀叙,由于Go調(diào)度不像OS調(diào)度那樣有時間片的概念略水,因此實際搶占機制要弱很多: Go中的搶占實際上是為G設(shè)置搶占標記(g.stackguard0),當G調(diào)用某函數(shù)時(更確切說匹耕,在通過newstack分配函數(shù)棧時)聚请,被編譯器安插的指令會檢查這個標記,并且將當前G以runtime.Goched的方式暫停稳其,并加入到全局隊列驶赏。但是這種需要函數(shù)調(diào)用主動配合的調(diào)度方式存在一些邊緣情況,就比如說下面的例子:
package main
import (
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
go func() {
for {
}
}()
time.Sleep(time.Millisecond)
println("OK")
}
其中創(chuàng)建一個goroutine并掛起既鞠, main goroutine 優(yōu)先調(diào)用了 休眠煤傍,此時唯一的 P 會轉(zhuǎn)去執(zhí)行 for 循環(huán)所創(chuàng)建的 goroutine,進而 main goroutine 永遠不會再被調(diào)度嘱蛋。換一句話說在Go1.14之前蚯姆,上邊的代碼永遠不會輸出OK,因為這種協(xié)作式的搶占式調(diào)度是不會使一個沒有主動放棄執(zhí)行權(quán)洒敏、且不參與任何函數(shù)調(diào)用的goroutine被搶占龄恋。
Go1.14 實現(xiàn)了基于信號的真搶占式調(diào)度解決了上述問題。Go1.14程序啟動時凶伙,在 runtime.sighandler 函數(shù)中注冊了 SIGURG 信號的處理函數(shù) runtime.doSigPreempt郭毕,在觸發(fā)垃圾回收的棧掃描時,調(diào)用函數(shù)掛起goroutine函荣,并向M發(fā)送信號显押,M收到信號后,會讓當前goroutine陷入休眠繼續(xù)執(zhí)行其他的goroutine傻挂。
SIGURG表示I/O緊急信號乘碑,屬于高優(yōu)先級信號,在進程忙時仍然會被優(yōu)先處理金拒,默認在Unix系統(tǒng)進程中是被忽略的兽肤,因此被Golang用于處理搶占式調(diào)度。