Go語言中GMP調(diào)度模型詳解
如果說 Goroutine 是 Go 并發(fā)的"工人",Channel 是"傳送帶",那 GMP 就是整個(gè)工廠的調(diào)度指揮中心。
一、Go 語言中的 GMP 模型是什么?
1.1 三個(gè)字母的本質(zhì)
GMP 是 Go Runtime 設(shè)計(jì)的三級調(diào)度抽象:
| 字母 | 全稱 | 本質(zhì) | 生活比喻 |
|---|---|---|---|
| G | Goroutine | 用戶任務(wù),待執(zhí)行的函數(shù) | 工人(帶著圖紙和工具,等待派活) |
| M | Machine | OS 線程(內(nèi)核線程),真正跑代碼的執(zhí)行引擎 | 機(jī)床/工位(有電、有馬達(dá),能干活) |
| P | Processor | 邏輯處理器,包含調(diào)度上下文和本地隊(duì)列 | 車間主任(手里有本地任務(wù)清單、工具箱、材料柜) |
1.2 三者如何協(xié)作?(核心關(guān)系)
就是這個(gè)模型讓Go能在少量線程上調(diào)度海量goroutine,是Go?并發(fā)的基礎(chǔ)。

鐵律:
- G 想執(zhí)行,必須先找到 P。沒有 P 的 G 只能去全局隊(duì)列排隊(duì)。
- P 想干活,必須先綁定 M。P 是邏輯概念,不能自己跑代碼,必須寄生在 M 上。
- M 可以沒有 P(比如執(zhí)行系統(tǒng)調(diào)用時(shí)),但此時(shí)它不能執(zhí)行用戶 G。
1.3 為什么需要三層?不是兩層就行了嗎?
P 是性能關(guān)鍵。
如果沒有 P,直接讓 G 和 M 綁定:
- M1 綁定了 G1,G1 去 Channel 阻塞了 → M1 這個(gè)內(nèi)核線程也跟著阻塞。
- 操作系統(tǒng)調(diào)度器會(huì)把 M1 切走,上下文切換開銷巨大。
- 所有 G 競爭一個(gè)全局隊(duì)列,鎖爭用慘烈。
有了 P 之后:
- G1 阻塞在 Channel,只是 G1 被掛起,M 可以立刻切換到 g0(后面講),然后綁定 P 去執(zhí)行別的 G。
- P 的本地隊(duì)列讓大部分調(diào)度無需加鎖(無鎖環(huán)形隊(duì)列)。
二、什么是 Go Scheduler?
2.1 本質(zhì)定義
Go Scheduler 是 Go Runtime 內(nèi)部實(shí)現(xiàn)的一個(gè)用戶態(tài)線程調(diào)度器(User-space Scheduler),運(yùn)行在用戶空間,與操作系統(tǒng)內(nèi)核調(diào)度器(如 Linux CFS)是上下級關(guān)系。
它不是操作系統(tǒng)的某個(gè)組件,而是編譯進(jìn)你程序里的 runtime 包代碼(主要在 runtime/proc.go、runtime/runtime2.go 中)。
2.2 它到底在管什么?
它只管理一件事:哪個(gè) G(Goroutine)應(yīng)該在哪個(gè) M(OS 線程)上執(zhí)行。
操作系統(tǒng)內(nèi)核調(diào)度器管理的是進(jìn)程/線程(M),它根本不知道 G 的存在。Go Scheduler 夾在操作系統(tǒng)和你的業(yè)務(wù)代碼之間,扮演"中間商":
2.3 為什么需要它?直接用操作系統(tǒng)線程不行嗎?
核心原因:OS 線程太重,Goroutine 太輕。
| 對比項(xiàng) | OS 線程(Linux pthread) | Goroutine |
|---|---|---|
| 棧大小 | 默認(rèn) 1~8 MB(固定或分頁增長) | 默認(rèn) 2 KB(可動(dòng)態(tài)增長) |
| 上下文切換 | 1~2 微秒(進(jìn)內(nèi)核,保存/恢復(fù)大量寄存器,刷新 TLB) | ~200 納秒(用戶態(tài),只改幾個(gè)寄存器) |
| 創(chuàng)建銷毀開銷 | 大(需內(nèi)核參與,分配大量資源) | 極?。ㄓ脩魬B(tài)分配,約 ~2KB 內(nèi)存) |
| 單機(jī)并發(fā)量 | 通常幾千個(gè)就是極限 | 輕松幾十萬甚至上百萬 |
如果沒有 Go Scheduler,一個(gè) Goroutine 綁定一個(gè) OS 線程:
- 100 萬個(gè) Goroutine = 100 萬個(gè)線程。
- 操作系統(tǒng)直接崩潰,內(nèi)存不夠,調(diào)度器本身也卡死。
Go Scheduler 通過 M:N 模型(少量 M 承載大量 G),把 Goroutine 的調(diào)度成本壓到極低。
2.4 Scheduler 的核心入口函數(shù)
Go 程序里每個(gè) M 都在執(zhí)行一個(gè)永不退出的調(diào)度循環(huán):
// runtime/proc.go 中的核心函數(shù)
func schedule() {
// 1. 找下一個(gè)可運(yùn)行的 G
gp := findRunnable()
// 2. 執(zhí)行它
execute(gp)
// 3. G 結(jié)束后,回到這里,繼續(xù)循環(huán)
}這個(gè) schedule() 函數(shù)就是 Go Scheduler 的心臟。你的程序里每個(gè) M(除了極少數(shù)特殊情況)都在死循環(huán)地調(diào)用它。
三、Go 調(diào)度策略是什么?
3.1 大前提:M:N 調(diào)度 + 兩級隊(duì)列
Go 的調(diào)度器采用的是 M:N 模型:
- M 個(gè) OS 線程,調(diào)度 N 個(gè) Goroutine(N >> M)。
- 調(diào)度隊(duì)列分兩級:P 的本地?zé)o鎖隊(duì)列(Local Runq)+ 全局隊(duì)列(Global Runq)。
3.2 搶占策略的演進(jìn)
Go 的調(diào)度不是純粹的協(xié)作式,也不是純粹的操作系統(tǒng)搶占式,而是"以協(xié)作為主,強(qiáng)制搶為輔"的混合策略。=
① Go 1.14 之前:協(xié)作式搶占(Cooperative Preemption)
原理: 編譯器在幾乎所有函數(shù)調(diào)用的入口處(函數(shù)序言,F(xiàn)unction Prologue),插入一段檢查代碼。這段代碼會(huì)檢查當(dāng)前 G 是否被標(biāo)記為需要讓出 CPU。
源碼層面的實(shí)現(xiàn): 每個(gè) G 結(jié)構(gòu)體里有一個(gè)字段叫 stackguard0。當(dāng) sysmon 認(rèn)為某個(gè) G 運(yùn)行太久了,就把它的 stackguard0 設(shè)置為一個(gè)特殊值 stackpreempt。
當(dāng) G 執(zhí)行到下一個(gè)函數(shù)調(diào)用時(shí),會(huì)檢查??臻g:
// 偽代碼,示意函數(shù)序言的檢查邏輯
if sp < stackguard0 { // 正常是檢查棧溢出
if stackguard0 == stackpreempt {
// 不是棧溢出,是被要求搶占了!
goschedImpl() // 主動(dòng)讓出 CPU
}
}致命缺陷: 如果某個(gè) Goroutine 寫了一個(gè)純死循環(huán),且循環(huán)體里沒有任何函數(shù)調(diào)用:
for {}那么它永遠(yuǎn)不會(huì)走到函數(shù)序言的檢查點(diǎn),preempt 標(biāo)志永遠(yuǎn)得不到檢查。這個(gè) G 會(huì)永久霸占所在的 M,同一個(gè) P 本地隊(duì)列里的其他 G 全部餓死。
② Go 1.14 之后:基于信號的異步搶占(Asynchronous Preemption)
Go 1.14 引入了新機(jī)制,解決了"無函數(shù)調(diào)用的死循環(huán)"問題。
完整流程:
Step 1:Sysmon 檢測
sysmon是一個(gè)特殊的 M(由 Runtime 啟動(dòng),不綁定 P),它死循環(huán)運(yùn)行。- 它定期檢查每個(gè) P 的
sysmontick記錄。 - 如果發(fā)現(xiàn)某個(gè) G 在同一個(gè) M 上運(yùn)行超過 10ms,判定為"需要搶占"。
Step 2:發(fā)送信號
sysmon向目標(biāo) M 發(fā)送一個(gè) Unix 信號SIGURG(緊急信號,用戶程序通常不用)。- 注意:不是直接操作 G,而是給 M 發(fā)信號。
Step 3:M 的信號處理
- 每個(gè) M 內(nèi)部都有一個(gè)專門的信號處理 Goroutine,叫做
gsignal。 - M 收到
SIGURG后,由gsignal接手處理(在信號上下文執(zhí)行)。 gsignal會(huì)設(shè)置目標(biāo) G 的搶占標(biāo)志,并記錄"異步搶占請求"。
Step 4:安全點(diǎn)搶占
- 信號處理程序不會(huì)直接打斷正在運(yùn)行的 G(那樣太危險(xiǎn),可能正在修改共享狀態(tài))。
- 它會(huì)在 G 到達(dá)下一個(gè)安全點(diǎn)(Safe Point)時(shí)觸發(fā)搶占。安全點(diǎn)包括:
- 函數(shù)調(diào)用入口
- 循環(huán)的 backedge(循環(huán)邊界)
- 某些特定的指令序列
- 在安全點(diǎn),Runtime 檢查搶占標(biāo)志,然后執(zhí)行和之前類似的
goschedImpl(),把 G 放回隊(duì)列。
關(guān)鍵結(jié)論:
- 1.14 之前:只有函數(shù)調(diào)用時(shí)才能被搶占。
- 1.14 之后:即使無函數(shù)調(diào)用,信號機(jī)制也能強(qiáng)制你在安全點(diǎn)停下。但注意,它仍然不是"隨時(shí)打斷",而是"在安全點(diǎn)檢查并打斷"。
四、發(fā)生調(diào)度的時(shí)機(jī)有哪些?
調(diào)度不是隨時(shí)發(fā)生的,而是在特定時(shí)機(jī)觸發(fā)。分為主動(dòng)、被動(dòng)、搶占三類:
4.1 主動(dòng)調(diào)度(Goroutine 自己讓出)
| 場景 | 函數(shù) | 說明 |
|---|---|---|
| 顯式讓出 | runtime.Gosched() | G 主動(dòng)說"我先歇會(huì)兒" |
| park | gopark() | Channel 阻塞、sleep、wait 等,G 掛起 |
| 結(jié)束 | G 的函數(shù) return | G 執(zhí)行完了,M 必須找新 G |
4.2 被動(dòng)調(diào)度(Goroutine 被迫讓出)
| 場景 | 說明 |
|---|---|
| 系統(tǒng)調(diào)用 | G 調(diào)用 read/write 等,M 進(jìn)入內(nèi)核態(tài),G 和 M 解綁,P 找新 M |
| Channel 阻塞 | 無緩沖 Channel 沒配對,G 掛到隊(duì)列,M 切換 |
| 鎖阻塞 | sync.Mutex 拿不到,G 進(jìn)等待隊(duì)列 |
| GC | 觸發(fā) GC 時(shí),所有 G 必須暫停(STW),調(diào)度器介入 |
| 棧增長 | G 的棧不夠用了,切換到 g0 分配新棧 |
4.3 搶占式調(diào)度(Go 1.14+ 的關(guān)鍵改進(jìn))
以前 Go 是協(xié)作式調(diào)度,如果 G 里寫了個(gè)死循環(huán)且不調(diào)用任何函數(shù),它會(huì)永遠(yuǎn)占著 CPU。
Go 1.14 引入了基于信號的搶占:
- 系統(tǒng)監(jiān)控線程(Sysmon)每 10ms 檢查一次。
- 發(fā)現(xiàn)某個(gè) G 運(yùn)行太久,就發(fā)信號(
SIGURG)給對應(yīng)的 M。 - M 收到信號后,在函數(shù)調(diào)用序言(prologue)處檢查搶占標(biāo)志,主動(dòng)讓出。
五、M 尋找可運(yùn)行 G 的過程是怎樣的?
這是 Go Scheduler 最核心的算法,我必須嚴(yán)格按照 runtime/proc.go 中 findrunnable() 的源碼邏輯,給你分步驟拆解。
總覽
當(dāng) M 上的當(dāng)前 G 執(zhí)行完畢(或阻塞)后,M 會(huì)調(diào)用 schedule(),進(jìn)而調(diào)用 findrunnable() 來找下一個(gè)活干。
Step 1:檢查 P 的 runnext(最快路徑)
if gp := pp.runnext; gp != nil {
pp.runnext = nil
return gp
}每個(gè) P 有一個(gè)單緩沖槽位叫 runnext。如果某個(gè) G 被標(biāo)記為"下一個(gè)優(yōu)先執(zhí)行"(比如剛被喚醒的高優(yōu)先級 G),直接拿走。無鎖,最快。
Step 2:從 P 的本地隊(duì)列?。╮unqget)
if gp, inheritTime := runqget(_p_); gp != nil {
return gp, inheritTime
}P 的 runq 是一個(gè)無鎖環(huán)形數(shù)組隊(duì)列(長度 256)。M 優(yōu)先從這里取 G。這也是無鎖操作,性能極高。
Step 3:從全局隊(duì)列取(globrunqget)
if gp := globrunqget(_p_, max); gp != nil {
return gp
}本地隊(duì)列空了。M 去全局隊(duì)列 sched.runq 取一批 G。
- 取多少個(gè)?
n = min(len(globalRunq), max),通常一次取 1 個(gè)或一批(具體算法會(huì)取一半,但上限約 128 個(gè))。 - 為什么取一批而不是一個(gè)?減少全局鎖競爭。
- 這批 G 被放到 P 的本地隊(duì)列,然后執(zhí)行第一個(gè)。
Step 4:檢查 Netpoller(網(wǎng)絡(luò)輪詢器)
if gp := netpoll(0); gp != nil {
return gp
}看看有沒有因?yàn)榫W(wǎng)絡(luò) I/O 阻塞、但底層 fd 已經(jīng)就緒的 G(比如 socket 收到數(shù)據(jù)了)。這是一個(gè)非阻塞調(diào)用。
Step 5:Work Stealing(工作竊?。?/h3>
// 嘗試 4 輪
for i := 0; i < 4; i++ {
// 隨機(jī)選擇其他 P
for _, p2 := range stealOrder {
if gp := runqsteal(_p_, p2); gp != nil {
return gp
}
}
}
- M 隨機(jī)挑選其他 P,嘗試從它們的本地隊(duì)列尾部偷一半任務(wù)過來。
- 偷取是批量的:把 victim P 隊(duì)列里后半部分的 G 一次性搬到當(dāng)前 P 的隊(duì)列。
- 嘗試 4 輪還偷不到,放棄。
// 嘗試 4 輪
for i := 0; i < 4; i++ {
// 隨機(jī)選擇其他 P
for _, p2 := range stealOrder {
if gp := runqsteal(_p_, p2); gp != nil {
return gp
}
}
}Step 6:再次檢查全局隊(duì)列和 Netpoller
偷了一圈回來,可能全局隊(duì)列有新任務(wù)了,或者網(wǎng)絡(luò)包到了。再檢查一次。
Step 7:GC 后臺(tái)任務(wù)
if gp := gcBgMarkWorker(); gp != nil {
return gp
}實(shí)在沒用戶任務(wù)了,M 可以幫 GC 做后臺(tái)標(biāo)記工作(并發(fā) GC 的一部分)。
Step 8:真的沒有了,M 進(jìn)入休眠(stopm)
stopm()
- M 和 P 解綁(P 被放回全局
pidle空閑列表)。 - M 自己被放入全局
midle空閑列表。 - 調(diào)用操作系統(tǒng)的線程休眠原語(如
futex/WaitForSingleObject),進(jìn)入睡眠狀態(tài)。 - 等待將來有任務(wù)時(shí),被
startm()或wakep()叫醒。

六、GMP 能不能去掉 P 層?去掉會(huì)怎么樣?
6.1 結(jié)論:絕對不能去掉
如果你把 P 層抽掉,只剩下 G 和 M 兩層,Go 的 Runtime 會(huì)在調(diào)度性能和內(nèi)存分配性能上同時(shí)崩塌。P 層存在的意義,遠(yuǎn)不止"一個(gè)中間層"這么簡單。
6.2 原因一:本地?zé)o鎖隊(duì)列(Local Runq)
這是 P 層最核心的功能。
有 P 時(shí)的場景:
- 每個(gè) P 有一個(gè)
runq[256]的環(huán)形數(shù)組。 - M 從 P 的本地隊(duì)列取 G 時(shí),不需要任何鎖(無鎖環(huán)形隊(duì)列,通過原子操作實(shí)現(xiàn))。
- 4 個(gè) P 就有 4 個(gè)獨(dú)立隊(duì)列,4 個(gè) M 可以同時(shí)取 G,互不干擾。
去掉 P 后的場景:
- 所有 G 只能擠在一個(gè)全局隊(duì)列里。
- 任何 M 想取 G,都必須先搶一把全局大鎖。
- 4 個(gè) M 同時(shí)取 G → 4 個(gè)線程搶同一把鎖 → 緩存一致性流量爆炸 → CPU 大量時(shí)間花在自旋等待上。
- 實(shí)測:高并發(fā)下,調(diào)度器本身會(huì)吃掉 30%~50% 的 CPU 時(shí)間。
6.3 原因二:mcache(內(nèi)存分配無鎖)
這是很多人忽略的一點(diǎn)。P 層不僅管調(diào)度,還管內(nèi)存分配。
Go 的內(nèi)存分配器采用 TCMalloc 模型,分為三級:
G ──? mcache(P 本地)──? mcentral(全局,按 span class 分)──? mheap(全局,向 OS 申請)
mcache 是什么?
- 每個(gè) P 自帶一個(gè)
mcache,里面緩存了各種規(guī)格(size class)的內(nèi)存塊(span)。 - 當(dāng) G 申請小對象(
< 32KB)時(shí),直接從 P 的mcache拿,不需要加鎖。 - 只有當(dāng)
mcache空了,才會(huì)去全局的mcentral補(bǔ)充(這時(shí)才需要鎖)。
去掉 P 后的場景:
- 所有 G 申請內(nèi)存都直接走全局
mcentral或mheap。 - 100 萬個(gè) G 同時(shí)搶內(nèi)存分配鎖 → 程序直接卡死。
- 有 P 時(shí),99% 的內(nèi)存分配是無鎖的。
6.4 原因三:調(diào)度上下文緩存
P 保存了一個(gè) G 運(yùn)行所需的完整上下文環(huán)境:
mcache:內(nèi)存分配緩存。pctrace:性能分析采樣狀態(tài)。defer pool:defer 結(jié)構(gòu)體緩存池,減少重復(fù)分配。sudog cache:Channel 操作時(shí)sudog對象的緩存池。writebuf:GC 寫屏障緩沖區(qū)。
這意味著: 當(dāng) G1 在 P 上阻塞后,P 可以立刻把 G1 的上下文凍結(jié),然后執(zhí)行 G2。G1 恢復(fù)時(shí),P 上的緩存還在,無需重建。
去掉 P 后: 每次切換 G,都要重新初始化內(nèi)存分配環(huán)境、重新分配 defer/sudog 緩存,開銷巨大。
七、P 和 M 在什么時(shí)候會(huì)被創(chuàng)建?
7.1 P 的創(chuàng)建時(shí)機(jī)
P 是程序啟動(dòng)時(shí)一次性創(chuàng)建的。
func main() {
? ?runtime.GOMAXPROCS(4) ?// 默認(rèn)等于 CPU 核心數(shù)
}具體流程:
- Go 程序啟動(dòng),執(zhí)行
runtime.rt0_go。 - 調(diào)用
schedinit(),其中讀取環(huán)境變量$GOMAXPROCS或調(diào)用runtime.GOMAXPROCS(n)。 - 調(diào)用
procresize(n),創(chuàng)建 n 個(gè) P。 - 這些 P 被放入全局
allp數(shù)組,同時(shí)大部分被放入 空閑池pidle。
P 的數(shù)量是固定的嗎?
- 運(yùn)行時(shí)可以動(dòng)態(tài)調(diào)整(
runtime.GOMAXPROCS(newN)),但極少這么做。 - 調(diào)整時(shí),多余的 P 會(huì)被剝離(上面的 G 轉(zhuǎn)移到其他 P),然后放入
pidle;不足的 P 從pidle取出或新建。
pidle 是什么?
pidle是全局的 P 空閑鏈表(singly-linked list)。- 當(dāng) M 進(jìn)入休眠(
stopm)時(shí),它綁定的 P 會(huì)被解綁,放入pidle。 - 當(dāng)有新任務(wù)時(shí),
wakep()或startm()從pidle取一個(gè) P,綁定到 M 上。
7.2 M 的創(chuàng)建時(shí)機(jī)
M 是 OS 線程,創(chuàng)建成本較高,Go 會(huì)盡量復(fù)用。M 是動(dòng)態(tài)創(chuàng)建的。
創(chuàng)建 M 的入口是 newm(),觸發(fā)場景:
場景 1:程序啟動(dòng)時(shí)
創(chuàng)建第一個(gè) M,即 m0(主線程)。
場景 2:有 G 要執(zhí)行,但沒有空閑 M
// 當(dāng)需要執(zhí)行 G 時(shí),調(diào)用 startm()
func startm(_p_ *p, spinning bool) {
? ?// 1. 先嘗試從 midle(M 空閑列表)拿一個(gè)休眠的 M
? ?mp := mget()
? ?if mp == nil {
? ? ? ?// 2. 沒有空閑 M,新建一個(gè)!
? ? ? ?newm(fn, _p_, spinning)
? ? ? ?return
? }
? ?// ...綁定 P,喚醒 M
}場景 3:G 進(jìn)入系統(tǒng)調(diào)用,當(dāng)前 M 阻塞
當(dāng) G 執(zhí)行 read() / write() 等系統(tǒng)調(diào)用時(shí):
- 當(dāng)前 M 進(jìn)入內(nèi)核態(tài),可能長時(shí)間阻塞。
- P 和 M 解綁(P 不能跟著 M 一起睡)。
- P 需要找新的 M 來繼續(xù)執(zhí)行其他 G。
- 如果
midle里沒有空閑 M,就調(diào)用newm()新建一個(gè)。
場景 4:GC 并行標(biāo)記需要
GC 的并發(fā)標(biāo)記階段可能需要額外的 M 并行工作。
midle 是什么?
midle是全局的 M 空閑鏈表。- M 沒活干時(shí)(
stopm),不會(huì)被操作系統(tǒng)銷毀,而是放入midle睡覺。 - 下次需要 M 時(shí),優(yōu)先從
midle喚醒,避免頻繁創(chuàng)建/銷毀 OS 線程。
八、m0 是什么?有什么用?
8.1 m0 的定義
m0 是 Go 程序啟動(dòng)時(shí),由操作系統(tǒng)創(chuàng)建的第一個(gè) OS 線程(主線程)。 它是 Go Runtime 的"盤古",一切從這里開始。
8.2 m0 的特殊身世
普通 M 是 Go Runtime 自己調(diào)用操作系統(tǒng) API(如 clone / CreateThread)創(chuàng)建的,但 m0 不是——它是操作系統(tǒng)啟動(dòng)你的程序時(shí),就已經(jīng)存在的那條線程。
操作系統(tǒng)加載可執(zhí)行文件 ? │ ? ▼ 創(chuàng)建進(jìn)程 + 主線程 ? │ ? ▼ 進(jìn)入 _rt0_amd64_linux(或?qū)?yīng)平臺(tái)的入口) ? │ ? ▼ 這條主線程,就是 m0
8.3 m0 的不可替代職責(zé)
| 階段 | m0 做什么 |
|---|---|
| Runtime 初始化 | 執(zhí)行 runtime.rt0_go,設(shè)置棧、初始化內(nèi)存分配器、初始化調(diào)度器 |
| 創(chuàng)建 G0 和 main goroutine | 創(chuàng)建第一個(gè) G(不是 g0,是執(zhí)行 main.main 的那個(gè) G) |
| 進(jìn)入調(diào)度循環(huán) | m0 也調(diào)用 mstart() → schedule(),和普通 M 一樣干活 |
| 兜底 | 如果所有其他 M 都阻塞在系統(tǒng)調(diào)用里,至少 m0 還在運(yùn)行,保證程序不死 |
8.4 m0 與普通 M 的區(qū)別
| 特性 | m0 | 普通 M |
|---|---|---|
| 創(chuàng)建者 | 操作系統(tǒng) | Go Runtime (newm) |
| g0 棧 | 使用操作系統(tǒng)分配的初始棧 | 使用 Runtime 在堆上分配的棧 |
| 生命周期 | 與進(jìn)程同生共死 | 可以休眠復(fù)用,理論上可銷毀(實(shí)際很少) |
| 數(shù)量 | 全局只有 1 個(gè) | 可以有多個(gè) |

九、g0 是一個(gè)怎樣的協(xié)程?有什么用?
9.1 g0 的定義
每個(gè) M 都有一個(gè)專屬的 g0,它是一個(gè)"系統(tǒng) Goroutine",不執(zhí)行用戶代碼,只執(zhí)行 Runtime 代碼。
9.2 g0 與普通 G 的本質(zhì)區(qū)別
| 特性 | 普通 G | g0 |
|---|---|---|
| 棧 | 2KB 起步,動(dòng)態(tài)增長(通常幾 KB 到幾 MB) | 較大(通常 8KB+),固定大小,系統(tǒng)棧 |
| 執(zhí)行內(nèi)容 | 用戶寫的函數(shù) | Runtime 內(nèi)部函數(shù)(schedule、GC、syscall 封裝) |
| 調(diào)度行為 | 被調(diào)度(被動(dòng)等待 M 執(zhí)行) | 主動(dòng)調(diào)度別人(執(zhí)行 schedule() 決定下一個(gè)跑誰) |
| 數(shù)量 | 用戶創(chuàng)建,無數(shù)多個(gè) | 每個(gè) M 嚴(yán)格只有 1 個(gè) |
9.3 g0 的三大核心作用
作用 1:調(diào)度中轉(zhuǎn)站(最重要的作用)
當(dāng) M 上的普通 G1 需要讓出 CPU 時(shí),必須切換到 g0,由 g0 執(zhí)行調(diào)度邏輯。
G1 正在執(zhí)行用戶代碼 ? │ ? ▼ G1 阻塞(Channel / Sleep / 系統(tǒng)調(diào)用) ? │ ? ▼ 保存 G1 的上下文(SP, PC, BP)到 G1.gobuf ? │ ? ▼ 【切換到 g0 棧】← 關(guān)鍵! ? │ ? ▼ g0 執(zhí)行 schedule() → findrunnable() → 找到 G2 ? │ ? ▼ 恢復(fù) G2 的上下文 ? │ ? ▼ 【從 g0 切換回 G2】 ? │ ? ▼ G2 開始執(zhí)行
為什么必須切到 g0? 因?yàn)?schedule() 本身是 Runtime 函數(shù),它如果跑在普通 G 的棧上,可能會(huì)污染用戶棧(比如占用太多??臻g、修改棧布局)。g0 有獨(dú)立的、足夠大的系統(tǒng)棧,可以安全地執(zhí)行復(fù)雜的調(diào)度邏輯。
作用 2:系統(tǒng)調(diào)用的代理
普通 G 執(zhí)行 read() / write() 時(shí):
- 切換到 g0 棧。
- g0 調(diào)用
entersyscall(),保存狀態(tài),把 G 標(biāo)記為_Gsyscall。 - g0 執(zhí)行真正的系統(tǒng)調(diào)用。
- 系統(tǒng)調(diào)用返回后,g0 調(diào)用
exitsyscall(),嘗試重新獲取 P,恢復(fù)用戶 G。
作用 3:棧增長管理
當(dāng)普通 G 的棧不夠用時(shí),會(huì)觸發(fā) morestack。這個(gè)函數(shù)運(yùn)行在 g0 棧上,負(fù)責(zé)分配更大的??臻g,拷貝舊棧數(shù)據(jù),然后切回用戶 G。
十、Go 棧和用戶棧是如何進(jìn)行切換的?
10.1 兩種棧的明確區(qū)分
| 棧 | 歸屬 | 用途 | 大小 |
|---|---|---|---|
| 用戶棧(G 棧) | 每個(gè)普通 G 獨(dú)有 | 執(zhí)行用戶函數(shù)、局部變量、defer、panic recover | 2KB 起步,可動(dòng)態(tài)增長 |
| 系統(tǒng)棧(g0 棧) | 每個(gè) M 的 g0 獨(dú)有 | 執(zhí)行 Runtime 代碼、調(diào)度、系統(tǒng)調(diào)用 | 較大,固定 |
10.2 切換的核心數(shù)據(jù)結(jié)構(gòu):gobuf
Go 在 G 結(jié)構(gòu)體里保存了切換所需的全部寄存器狀態(tài):
type gobuf struct {
? ?sp ? uintptr ?// Stack Pointer:棧頂指針
? ?pc ? uintptr ?// Program Counter:下一條要執(zhí)行的指令地址
? ?g ? ?uintptr ?// 指向當(dāng)前 G 的指針(恢復(fù)時(shí)知道切到哪個(gè) G)
? ?bp ? uintptr ?// Frame Pointer:幀指針/基址指針
? ?ret ?uintptr ?// 返回值(某些架構(gòu)用)
? ?lr ? uintptr ?// Link Register:返回地址(ARM 等架構(gòu)用)
}10.3 完整切換過程(以 G1 阻塞、切換到 G2 為例)
階段 A:保存 G1 的現(xiàn)場(G1 → g0)
發(fā)生時(shí)機(jī): G1 執(zhí)行到 Channel 發(fā)送/接收、Sleep、鎖阻塞等。
; 偽匯編 ; 1. 把 G1 的寄存器保存到 G1.sched(gobuf) MOVQ SP, G1.sched.sp ? ? ?; 保存棧指針 MOVQ PC, G1.sched.pc ? ? ?; 保存程序計(jì)數(shù)器(當(dāng)前指令地址) MOVQ BP, G1.sched.bp ? ? ?; 保存幀指針 MOVQ $G1, G1.sched.g ? ? ?; 保存 G 指針 ? ; 2. 修改 G1 狀態(tài) MOVQ $_Gwaiting, G1.status ?; G1 變成等待狀態(tài) ? ; 3. 切換到 g0 棧 MOVQ g0.sched.sp, SP ? ? ? ?; SP 切到 g0 的棧頂 MOVQ g0, g ? ? ? ? ? ? ? ? ?; 當(dāng)前 G 寄存器指向 g0 ? ; 4. 調(diào)用 runtime.schedule() CALL runtime.schedule(SB)
階段 B:g0 執(zhí)行調(diào)度
func schedule() {
? ?gp := findRunnable() ?// 找到 G2
? ?execute(gp) ? ? ? ? ? // 準(zhǔn)備執(zhí)行 G2
}階段 C:恢復(fù) G2 的現(xiàn)場(g0 → G2)
; 偽匯編 ; 1. 修改 G2 狀態(tài) MOVQ $_Grunning, G2.status ? ; 2. 從 G2.sched 恢復(fù)寄存器 MOVQ G2.sched.sp, SP ? ? ?; 恢復(fù)棧指針 MOVQ G2.sched.pc, PC ? ? ?; 恢復(fù)程序計(jì)數(shù)器(下一條指令) MOVQ G2.sched.bp, BP ? ? ?; 恢復(fù)幀指針 MOVQ G2.sched.g, g ? ? ? ?; 當(dāng)前 G 寄存器指向 G2 ? ; 3. 跳轉(zhuǎn)到 G2 的 PC 繼續(xù)執(zhí)行 JMP G2.sched.pc
關(guān)鍵點(diǎn): 這不是操作系統(tǒng)級別的線程切換(不需要進(jìn)內(nèi)核),只是修改了幾個(gè) CPU 寄存器(SP、PC、BP、g),所以速度極快(約 200 納秒)。
10.4 系統(tǒng)調(diào)用時(shí)的特殊切換
當(dāng) G1 執(zhí)行 read() 時(shí),切換更復(fù)雜:
G1 用戶棧 ? │ ? ▼ 調(diào)用 syscall.Read ? │ ? ▼ 【切換到 g0 ?!? ? │ ? ▼ g0 調(diào)用 entersyscall() ? ├── 保存 G1 上下文 ? ├── G1.status = _Gsyscall ? ├── P 和 M 解綁(P 可以去找別的 M) ? └── 釋放 P 的鎖 ? │ ? ▼ g0 執(zhí)行真正的 syscall(進(jìn)入內(nèi)核態(tài)) ? │ ? ▼ 內(nèi)核完成 read,返回用戶態(tài) ? │ ? ▼ g0 調(diào)用 exitsyscall() ? ├── 嘗試重新綁定原來的 P ? ├── 如果 P 被搶走了 → G1 進(jìn)全局隊(duì)列 ? └── 如果綁定成功 → 恢復(fù) G1 上下文 ? │ ? ▼ 【切回 G1 用戶?!? ? │ ? ▼ G1 繼續(xù)執(zhí)行
十一:GOMAXPROCS=4,100 萬個(gè) Goroutine,P 只有 4 個(gè),為什么不會(huì)成為瓶頸?
答案分三層:
① P 是"邏輯處理器",不是"執(zhí)行單元" 真正消耗 CPU 的是 M(OS 線程)。P 只是一個(gè)調(diào)度上下文,本身不消耗 CPU 時(shí)間。4 個(gè) P 意味著最多 4 個(gè) M 同時(shí)執(zhí)行用戶代碼,這與你的 4 核 CPU 完全匹配。
② 100 萬個(gè) G 大部分時(shí)間在睡覺 在真實(shí)的高并發(fā)程序中(如 Web 服務(wù)器),絕大多數(shù) G 處于:
- 等待網(wǎng)絡(luò) I/O(
netpoll管理) - 等待 Channel
- 等待鎖
- 等待定時(shí)器
這些 G 不需要占用 P。只有就緒狀態(tài)(_Grunnable)的 G 才需要排隊(duì)等 P。通常就緒的 G 數(shù)量遠(yuǎn)小于 100 萬。
③ G 的本地隊(duì)列分散了壓力
- 4 個(gè) P,每個(gè) P 的本地隊(duì)列可容納 256 個(gè) G。
- 4 個(gè) P 合計(jì)能緩存 1024 個(gè)就緒 G,無需加鎖。
- 全局隊(duì)列作為兜底,Work Stealing 保證負(fù)載均衡。
④ G 本身極輕量
- 每個(gè) G 初始棧 2KB,100 萬個(gè) G 約 2GB 內(nèi)存。
- 現(xiàn)代服務(wù)器輕松承受。且棧是按需增長的,很多 G 實(shí)際只占用幾 KB。
十二:純死循環(huán)for {},Go 1.14 前后分別發(fā)生什么?
Go 1.14 之前:
- 協(xié)作式搶占,依賴函數(shù)調(diào)用序言檢查
preempt標(biāo)志。 for {}循環(huán)體里沒有任何函數(shù)調(diào)用,永遠(yuǎn)不會(huì)走到檢查點(diǎn)。sysmon雖然能檢測到 G 運(yùn)行超 10ms,也設(shè)置了preempt標(biāo)志,但 G 永遠(yuǎn)不去檢查。- 結(jié)果:這個(gè) G 永久霸占一個(gè) M 和 P,同 P 本地隊(duì)列里的其他 G 全部餓死。程序表現(xiàn)為"卡死"(部分邏輯不執(zhí)行)。
Go 1.14 之后:
sysmon檢測到 G 運(yùn)行超 10ms,向 M 發(fā)送SIGURG信號。- M 的信號處理程序(
gsignal)在循環(huán)的安全點(diǎn)(backedge)設(shè)置搶占標(biāo)記。 - 即使
for {}沒有函數(shù)調(diào)用,循環(huán)跳轉(zhuǎn)指令(JMP)本身被編譯器標(biāo)記為安全點(diǎn)。 - 下一次循環(huán)迭代時(shí),檢查到搶占標(biāo)記,G 主動(dòng)讓出 CPU。
- 結(jié)果:G 運(yùn)行約 10ms 后被強(qiáng)制切走,其他 G 得到執(zhí)行機(jī)會(huì)。
十三:棧切換時(shí)除了 SP/PC,還需要保存什么?為什么 BP 很重要?
gobuf 中保存的關(guān)鍵信息:
- SP(棧指針):知道棧頂在哪,恢復(fù)后才能正確讀寫局部變量。
- PC(程序計(jì)數(shù)器):知道下一條該執(zhí)行哪條指令,恢復(fù)后才能繼續(xù)執(zhí)行。
- G(Goroutine 指針):知道當(dāng)前是哪個(gè) G,恢復(fù)后 M 的
curg才能正確指向它。 - BP(幀指針/基址指針):用于棧幀回溯。
為什么 BP 極其重要?
BP 指向當(dāng)前棧幀的底部。通過 BP 鏈表,可以回溯整個(gè)調(diào)用鏈:
當(dāng)前函數(shù)的 BP ──? 調(diào)用者的 BP ──? 調(diào)用者的調(diào)用者的 BP ──? ...
如果沒有 BP:
- Panic 時(shí)無法打印堆棧:
runtime.Callers和runtime.Stack依賴 BP 鏈表回溯調(diào)用鏈。 - GC 無法準(zhǔn)確掃描棧:GC 需要知道棧上哪些位置是指針。通過 BP 和編譯器生成的棧地圖(stack map),GC 才能正確識別存活對象。
- Defer 和 Recover 無法定位:
defer鏈表掛在棧幀上,沒有 BP 就找不到它們。
Go 在 AMD64 架構(gòu)上默認(rèn)啟用幀指針(-fno-omit-frame-pointer),就是為了保證調(diào)試和 GC 的正確性。
到此這篇關(guān)于Go語言中GMP調(diào)度模型詳解的文章就介紹到這了,更多相關(guān)go gmp調(diào)度模型內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Golang使用Gin實(shí)現(xiàn)文件上傳的示例代碼
本文我們主要介紹了Golang如何使用Gin實(shí)現(xiàn)文件上傳,Go標(biāo)準(zhǔn)庫net/http對文件上傳已經(jīng)提供了非常完善的支持,而Gin框架在其基礎(chǔ)上進(jìn)一步封裝,因此使用Gin開發(fā)文件上傳功能時(shí),只需要簡單幾行代碼便可以實(shí)現(xiàn),需要的朋友可以參考下2024-02-02
從基礎(chǔ)到高級詳解Go語言中錯(cuò)誤處理的實(shí)踐指南
Go語言采用了一種獨(dú)特而明確的錯(cuò)誤處理哲學(xué),與其他主流編程語言形成鮮明對比,本文將為大家詳細(xì)介紹Go語言中錯(cuò)誤處理詳細(xì)方法,希望對大家有所幫助2025-09-09
Golang巧用defer進(jìn)行錯(cuò)誤處理的方法
錯(cuò)誤處理是程序的重要組成部分,有效且優(yōu)雅的處理錯(cuò)誤是大多數(shù)程序員的追求,下面這篇文章主要給大家介紹了關(guān)于Golang中巧用defer進(jìn)行錯(cuò)誤處理的方法,文中通過示例介紹的非常詳細(xì),對大家具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面來一起看看吧。2017-05-05
golang如何使用struct的tag屬性的詳細(xì)介紹
這篇文章主要介紹了golang如何使用struct的tag屬性的詳細(xì)介紹,從例子說起,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-11-11
5個(gè)可以在Golang中優(yōu)化代碼以提高性能的技巧分享
作為一名軟件工程師,確保你的代碼高效且性能良好是非常重要的。本文主要和大家分享5個(gè)可以在Golang中優(yōu)化代碼以提高性能的技巧,希望對大家有所幫助2023-03-03
gin項(xiàng)目部署到服務(wù)器并后臺(tái)啟動(dòng)的步驟
本文主要介紹了gin項(xiàng)目部署到服務(wù)器并后臺(tái)啟動(dòng)的步驟,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-02-02
Go語言for range(按照鍵值循環(huán))遍歷操作
這篇文章主要介紹了Go語言for range(按照鍵值循環(huán))遍歷操作,具有很好的參考價(jià)值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-12-12

