1. 前言
Go 語(yǔ)言最大的魅力就是只需要 go
關(guān)鍵字即可快速創(chuàng)建一個(gè) goroutine ,無(wú)需關(guān)注操作系統(tǒng)的調(diào)度細(xì)節(jié),即可利用上多核輕松開(kāi)發(fā)出高并發(fā)的服務(wù)器應(yīng)用。理解了 Golang 調(diào)度器的機(jī)制,能讓我們寫(xiě)出高性能的并發(fā)程序,也可以提升我們的問(wèn)題定位、性能優(yōu)化的能力。
2. 進(jìn)程、線程、協(xié)程
任何并發(fā)程序,無(wú)論在應(yīng)用層是何形式,最終都要被操作系統(tǒng)所管理。由此涉及到以下幾個(gè)概念:
-
進(jìn)程 (Process):操作系統(tǒng)分配資源的基本單位 -
線程 (Thread):CPU 調(diào)度的基本單位,一個(gè)核上同時(shí)只能運(yùn)行一個(gè)線程,單線程的棧內(nèi)存大小約 1MB -
協(xié)程 (Coroutine):輕量級(jí)、用戶態(tài)線程。在 Go 語(yǔ)言中,有 goroutine
的概念。當(dāng)一個(gè)goroutine
被創(chuàng)建出來(lái)時(shí),分配的棧大小是 2KB,可見(jiàn)其「輕量」
3. 早期的調(diào)度模型:GM 模型
系統(tǒng)的設(shè)計(jì)往往都不是一蹴而就的,伴隨著演進(jìn)和優(yōu)化,Golang scheduler 也不例外。1.1
版本前的 go,使用的是相對(duì)簡(jiǎn)單的 GM
模型來(lái)處理 goroutine 的調(diào)度,GM
實(shí)際是兩個(gè)結(jié)構(gòu)體,即:
-
G(Goroutine): 協(xié)程結(jié)構(gòu)體,使用 go func()
時(shí),就會(huì)創(chuàng)建一個(gè)G
-
M(machine): 處理線程操作的結(jié)構(gòu)體,與操作系統(tǒng)直接交互
GM 模型包含一個(gè)全局協(xié)程隊(duì)列,即所有新建的 G
對(duì)象都會(huì)排隊(duì)等待 M
的處理。如圖:
這樣的設(shè)計(jì)在高并發(fā)下會(huì)帶來(lái)以下問(wèn)題:
What’s wrong with current implementation:
Single global mutex (Sched.Lock) and centralized state. The mutex protects all goroutine-related operations (creation, completion, rescheduling, etc). Goroutine (G) hand-off (G.nextg). Worker threads (M’s) frequently hand-off runnable goroutines between each other, this may lead to increased latencies and additional overheads. Every M must be able to execute any runnable G, in particular the M that just created the G. Per-M memory cache (M.mcache). Memory cache and other caches (stack alloc) are associated with all M’s, while they need to be associated only with M’s running Go code (an M blocked inside of syscall does not need mcache). A ratio between M’s running Go code and all M’s can be as high as 1:100. This leads to excessive resource consumption (each MCache can suck up up to 2M) and poor data locality. Aggressive thread blocking/unblocking. In presence of syscalls worker threads are frequently blocked and unblocked. This adds a lot of overhead.
(原文見(jiàn) Scalable Go Scheduler Design Doc)
翻譯總結(jié)一下,即存在以下優(yōu)化點(diǎn):
-
全局鎖、中心化狀態(tài)帶來(lái)的鎖競(jìng)爭(zhēng)導(dǎo)致的性能下降 -
M 會(huì)頻繁交接 G,導(dǎo)致額外開(kāi)銷(xiāo)、性能下降。每個(gè) M 都得能執(zhí)行任意的 runnable 狀態(tài)的 G -
對(duì)每個(gè) M 的不合理的內(nèi)存緩存分配,進(jìn)行系統(tǒng)調(diào)用被阻塞住的 M 其實(shí)不需要分配內(nèi)存緩存 -
強(qiáng)線程阻塞/解阻塞。頻繁的系統(tǒng)調(diào)用導(dǎo)致的線程阻塞/解阻塞帶來(lái)了大量的系統(tǒng)開(kāi)銷(xiāo)。
由此演進(jìn)出經(jīng)典的 GMP
調(diào)度模型
4. 高效的 GMP 調(diào)度模型
為了解決 G
和 M
調(diào)度低效的問(wèn)題,中間層 P(Processor)
被引入了。
-
P(Processor)
即管理goroutine
資源的處理器
由此得以將原先的系統(tǒng)線程資源管理 M
與 goroutine
對(duì)象 G
解耦。
除此之外,GMP 還引入了幾個(gè)新概念:
-
LRQ (Local Runnable Queue)
本地可運(yùn)行隊(duì)列,這個(gè)「本地」,是針對(duì) P 而言的,是指掛載在一個(gè) P 上的可運(yùn)行 G 隊(duì)列 -
GRQ (Global Runnable Queue)
全局可運(yùn)行隊(duì)列
如下圖所示:
LRQ 與 GRQ
從 runtime 源碼可知,整體的調(diào)度流程如下:
runtime.schedule() {
// only 1/61 of the time, check the global runnable queue for a G.
// if not found, check the local queue.
// if not found,
// try to steal from other Ps.
// if not, check the global runnable queue.
// if not found, poll .NETwork.
}
翻譯一下,即
-
只有 1/61 的情況下,會(huì)檢查 GRQ 來(lái)獲取一個(gè) G 來(lái)運(yùn)行 -
如果沒(méi)找到,則檢查 LRQ -
如果 LRQ 也為空,則嘗試從其它 P 上偷 G -
如果偷不到,就再檢查 GRQ -
如果還是沒(méi)事干,就會(huì)從 Network Poller
上拿一個(gè)。這里的Network Poller
是 go 管理網(wǎng)絡(luò)調(diào)用的模塊,如下圖,當(dāng)出現(xiàn)網(wǎng)絡(luò) IO 時(shí),就會(huì)將當(dāng)前的 G 移交到Network Poller
處理。P.S.Network Poller
的實(shí)現(xiàn),又可以扯一篇文章出來(lái)了,以后有空專(zhuān)門(mén)開(kāi)一篇分析。
于是 G1 被挪到 Net Poller 進(jìn)行異步網(wǎng)絡(luò)調(diào)用,現(xiàn)在 M 就可以執(zhí)行 G2 了
Work-stealing 機(jī)制
GMP
高性能的關(guān)鍵,在于引入了 work-stealing
機(jī)制,如下圖所示:
work-stealing 機(jī)制
當(dāng)一個(gè)新的 G 被創(chuàng)建時(shí),它會(huì)被追加到它當(dāng)前 P 的 LRQ 隊(duì)尾。當(dāng)一個(gè) G 被執(zhí)行完時(shí),它當(dāng)前的 P 會(huì)先嘗試從自己的 LRQ 中 pop 一個(gè) G 出來(lái)執(zhí)行,如果此時(shí)隊(duì)列為空,則會(huì)隨機(jī)選取一個(gè) P 并偷走它一半的 G ,對(duì)應(yīng)圖中,也就是 3 個(gè) G
這就好比公司里的卷王,自己的需求做完了,還要悄悄把摸魚(yú)大王的需求清單里一半的需求都掛到自己名下,囧
最后一個(gè)疑問(wèn):GRQ 什么時(shí)候被寫(xiě)入呢?
這又涉及到 G 創(chuàng)建時(shí)的分配流程了,我們知道,goroutine 都是由老的 goroutine 分配出來(lái)的,mAIn 函數(shù)也不例外,因此每個(gè) G 被分配的時(shí)候已經(jīng)有一個(gè)老 G 和對(duì)應(yīng)的老 P 了,在掛載 G 到 P 上時(shí),也會(huì)優(yōu)先掛在到老 P 的 LRQ 上去。但是老 P 的 LRQ 其實(shí)是有限的,當(dāng)掛滿的時(shí)候,這個(gè)新 G 就只能掛到 GRQ 上,等待被調(diào)度了。可以參考源碼中 P 的定義,其中 runq
就是 GRQ 了:
type p struct {
...
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32
runqtail uint32
runq [256]guintptr
...
}
5. 小結(jié)
本文講解了 go 語(yǔ)言調(diào)度器的發(fā)展及基于 GMP 的 work-stealing 策略,這個(gè)「偷」可以說(shuō)是非常精妙啦~ 這是 「Golang 并發(fā)編程」 系列的第一篇文章,如果對(duì)你有幫助,可以點(diǎn)個(gè)關(guān)注/在看來(lái)激勵(lì)我繼續(xù)寫(xiě)文章~