Go Scheduler 的 GMP 模型
寫在前面
Go 為了自身 goroutine 執(zhí)行和調(diào)度的效率,自身在 runtime 中實(shí)現(xiàn)了一套 goroutine 的調(diào)度器,下面通過一段簡(jiǎn)單的代碼展示一下 Go 應(yīng)用程序在運(yùn)行時(shí)的 goroutine,方便大家更好的理解。
The Go scheduler is part of the Go runtime, and the Go runtime is built into your application
上面這段代碼的輸出為:5。說明當(dāng)前這個(gè)應(yīng)用程序中存在 goroutine 的數(shù)量是 5,事實(shí)上也符合我們的預(yù)期。那么問題來了,這 5 個(gè) goroutine 作為操作系統(tǒng)用戶態(tài)的基本調(diào)度單元是無法直接占用操作系統(tǒng)的資源來執(zhí)行的,必須經(jīng)過內(nèi)核級(jí)線程的分發(fā),這是操作系統(tǒng)內(nèi)部線程調(diào)度的基本模型,根據(jù)用戶級(jí)線程和內(nèi)核級(jí)線程的對(duì)應(yīng)關(guān)系可以分為 1 對(duì) 1,N 對(duì) 1 以及 M 對(duì) N 這三種模型,那么上述的 5 個(gè) goroutine 在內(nèi)核級(jí)線程上是怎么被分發(fā)的,這就是 Go語言的 goroutine 調(diào)度器決定的。
GMP 模型
整個(gè) goroutine 調(diào)度器的實(shí)現(xiàn)基于 GMP 的三級(jí)模型來實(shí)現(xiàn)。
- G:goroutine
- M:內(nèi)核級(jí)線程,運(yùn)行在操作系統(tǒng)的核心態(tài)。在 Go 中支持最大的 M 的數(shù)量是 10000,但是操作系統(tǒng)中通常情況是不可以創(chuàng)建這么多的線程。
- P:processor,可以理解成一個(gè)等待分發(fā)給 M 調(diào)度執(zhí)行的 goroutine 隊(duì)列。P的個(gè)數(shù)是由 runtime 的 GOMAXPROCS 來決定的。
M 和 P 存在一一對(duì)應(yīng)的綁定關(guān)系。大致的結(jié)構(gòu)圖如下所示:
goroutine 之旅
通常情況下,我們?cè)诖a中執(zhí)行 go func(){}后,GMP 模型是如何工作的?通過一個(gè)詳細(xì)的圖來展示一下。
- 首先創(chuàng)建一個(gè)新的 goroutine
- 如果本地的局部隊(duì)列中有足夠的空間可以存放,則放入局部隊(duì)列中;如果局部隊(duì)列滿,則放入一個(gè)全局隊(duì)列(所有的 M 都可以從全局隊(duì)列中拉取 G 來執(zhí)行)
- 所有的 G 都必須在 M 上才可以被執(zhí)行,M 和 P 存在一一綁定的關(guān)系,如果 M 綁定的 P 中存在可以被執(zhí)行的 G,則從 P 中拉取 G 來執(zhí)行;如果 P 中為空,沒有可執(zhí)行的 G,則 M 從全局隊(duì)列中拉取;如果全局隊(duì)列也為空,則從其他的 P 中拉取 G
- 為 G 的運(yùn)行分配必要的資源,等待 CPU 的調(diào)度
- 分配到 CPU,執(zhí)行 func(){}
調(diào)度策略
整個(gè) goroutine 調(diào)度器最重要的調(diào)度策略是:復(fù)用,避免頻繁的資源創(chuàng)建和銷毀,最大限度的提升系統(tǒng)的吞吐量和并發(fā)程度。這也是操作系統(tǒng)進(jìn)行線程調(diào)度的終極目標(biāo)。復(fù)用(reuse)也是很多「池化技術(shù)」的基礎(chǔ)。
圍繞著這一原則,goroutine 調(diào)度器在以下幾個(gè)方面進(jìn)行調(diào)度策略的優(yōu)化。
- 工作隊(duì)列的竊取機(jī)制:這個(gè)跟 Java 中的 ForkJoin Pool 的竊取機(jī)制同一原理,都是當(dāng)線程 M 空閑時(shí),從其他繁忙的隊(duì)列 P 中"竊取"任務(wù) G 過來執(zhí)行,而不是銷毀空閑的 M。因?yàn)榫€程的創(chuàng)建和銷毀是需要消耗系統(tǒng)資源的,避免線程的頻繁創(chuàng)建和銷毀可以極大的提升系統(tǒng)的并發(fā)程度。
- 交接機(jī)制:當(dāng)線程M被阻塞的時(shí)候,M 會(huì)主動(dòng)將 P 交接給其他空閑的 M。
另外,在 go 的 1.14 版本中,go 語言的技術(shù)團(tuán)隊(duì)嘗試在調(diào)度器中添加了可搶占的技術(shù)[https://github.com/golang/go/issues/24543]。
- 搶占技術(shù)的出現(xiàn)一方面解決了線程 M 在執(zhí)行計(jì)算密集型任務(wù)時(shí)長(zhǎng)時(shí)間占用 CPU,導(dǎo)致與之綁定的 P 上的其他 G 得不到執(zhí)行而造成的"饑餓現(xiàn)象";
- 另一方面,搶占技術(shù)的出現(xiàn)對(duì) GC 來講解決 GC 時(shí)可能出現(xiàn)的 deadLock,相關(guān)的 issue 見:關(guān)于 GC 時(shí) tight loops 應(yīng)該可以被搶占的討論
[https://github.com/golang/go/issues/10958]
。
最開始的 MG 模型
在 go 語言的早期,goroutine 調(diào)度器的模型并不是 GMP,而是 GM。整個(gè)調(diào)度器維護(hù)一個(gè)全局的 G 的等待隊(duì)列,所有的 M 從這個(gè)全局的隊(duì)列中拉取 G 來執(zhí)行,在 go1.1 中將這種模型直接干掉,取而代之的是現(xiàn)在的 GMP 模型,在 GM 模型的基礎(chǔ)上增加 P 局部隊(duì)列。官方之所有這么這么做,原因有二:
- 全局的 G 等待隊(duì)列,不同的M從隊(duì)列里取 G 都需要加鎖,鎖的粒度很大,嚴(yán)重制約了系統(tǒng)并發(fā)能力的提升;
- 沒有局部隊(duì)列,那么當(dāng)線程在執(zhí)行 IO 密集型操作時(shí),M 阻塞在 IO 操作上,對(duì)應(yīng)的 G 也沒有辦法得到執(zhí)行(GMP 中可以將 G 交接給其他的 M 執(zhí)行),因此 GM 模型在應(yīng)對(duì) IO 密集型任務(wù)時(shí)性能表現(xiàn)低下。