如果你已經(jīng)理解了 epoll 是什么,也看懂了 Golang 對(duì)它的封裝,那我們現(xiàn)在就來回答一個(gè)更深的問題:
一個(gè) goroutine 卡在 conn.Read 上的時(shí)候,它到底卡在哪了?CPU 在干嘛?其他 goroutine 為啥還能跑?
這個(gè)問題的答案,就在 Golang 的 GPM 模型 里。
一、先復(fù)習(xí)一下:GPM 是啥?
Golang 的運(yùn)行時(shí)調(diào)度器有三個(gè)核心角色:
- G(Goroutine):就是你的代碼執(zhí)行流,一個(gè)輕量級(jí)線程
-
P(Processor):邏輯處理器,可以理解為"運(yùn)行 G 所需的上下文環(huán)境",通常
GOMAXPROCS決定了 P 的數(shù)量 - M(Machine):操作系統(tǒng)線程,真正干活的東西,一個(gè) M 必須綁定一個(gè) P 才能執(zhí)行 G
它們的關(guān)系是:
M 從 P 的本地隊(duì)列里取 G 來執(zhí)行,M 如果沒有 P,就相當(dāng)于空轉(zhuǎn)的線程,啥也干不了。
畫個(gè)簡圖:
M(線程) —— 綁定 —— P(處理器) —— 運(yùn)行 —— G(你的代碼)
|
本地隊(duì)列: G1, G2, G3...
二、當(dāng)你的代碼執(zhí)行到 conn.Read
現(xiàn)在我們回到最開始的那行代碼:
n, _ := conn.Read(buf)
假設(shè)這是在一個(gè) goroutine 里跑的,我們就叫它 G_user。
場景:數(shù)據(jù)還沒來
這時(shí)候,G_user 執(zhí)行到 conn.Read,經(jīng)過我們上一篇文章講的一層層封裝,最終調(diào)用了 gopark()。
gopark() 是啥?
它是 Golang 運(yùn)行時(shí)的一個(gè)函數(shù),作用就一句話:
把當(dāng)前這個(gè) G 從"運(yùn)行中"狀態(tài),變成"等待"狀態(tài),讓出 M,讓 M 可以去干別的事。
三、gopark() 那一刻發(fā)生了什么?
我們結(jié)合 GPM 模型,拆解一下這一步的細(xì)節(jié):
1. 初始狀態(tài)
- M_1 綁定在 P_1 上
- P_1 的本地隊(duì)列里有 G_user(正在運(yùn)行)和其他幾個(gè) G(等待運(yùn)行)
- 全局調(diào)度器還有一堆 G 在等著
M1 —— P1 —— [G_user(運(yùn)行中), G2(等待), G3(等待)]
2. G_user 調(diào)用 conn.Read
發(fā)現(xiàn)沒數(shù)據(jù),進(jìn)入 gopark()。
3. gopark() 的核心動(dòng)作
// 偽代碼
func gopark() {
// 1. 把當(dāng)前 G 的狀態(tài)從 _Grunning 改成 _Gwaiting
curg.status = _Gwaiting
// 2. 把這個(gè) G 和它等待的事件(這里是 fd 可讀)關(guān)聯(lián)起來
// 這一步會(huì)把這個(gè) G 的地址存到 pollDesc 里
pollDesc.waitingG = curg
// 3. 調(diào)用 schedule(),讓當(dāng)前 M 去找別的 G 來運(yùn)行
schedule()
}
關(guān)鍵點(diǎn)來了:gopark 不是讓 M 停下來,而是讓 G 停下來,然后 M 立刻去找下一個(gè) G 繼續(xù)跑。
4. schedule() 干了啥?
schedule() 是 Golang 調(diào)度器的核心函數(shù),它的邏輯大致是:
- 先看看能不能從 P 的本地隊(duì)列里拿一個(gè) G
- 如果本地隊(duì)列空了,就去全局隊(duì)列里偷一批 G 過來
- 如果全局隊(duì)列也空了,就去"偷"別的 P 的 G(work stealing)
- 如果啥都沒了,M 就可能進(jìn)入休眠,或者被銷毀
在我們的例子里,P_1 本地隊(duì)列還有 G2、G3,所以:
M1 找到 G2,開始執(zhí)行 G2
P1 現(xiàn)在變成:[G2(運(yùn)行中), G3(等待)],G_user 被標(biāo)記為等待,不在隊(duì)列里了
這就是為什么一個(gè) goroutine 卡在 I/O 上,不影響其他 goroutine 的原因:
- G_user 停了,但 M 沒停,它換了個(gè) G 繼續(xù)跑
- P 一直在工作,CPU 利用率拉滿
四、那 G_user 去哪了?
G_user 現(xiàn)在處于 _Gwaiting 狀態(tài),它被存放在兩個(gè)地方:
- 在它自己的棧里(內(nèi)存還在,隨時(shí)可以恢復(fù))
- 在它等待的那個(gè) pollDesc 里(這樣數(shù)據(jù)來了能找到它)
它的"等待隊(duì)列"不是 P 的隊(duì)列,而是一個(gè)專門的事件等待隊(duì)列——也就是 epoll 監(jiān)控的那個(gè)文件描述符對(duì)應(yīng)的等待列表。
畫一下現(xiàn)在的狀態(tài):
P1 —— M1 —— [G2(運(yùn)行中), G3(等待)]
↑
| (M1 換了個(gè) G 跑)
|
G_user(等待) —— 關(guān)聯(lián)到 pollDesc —— 關(guān)聯(lián)到 fd=3
五、另一邊:netpoller 怎么喚醒 G_user?
還記得上一篇文章說的 netpoll 函數(shù)嗎?它在后臺(tái)默默干著幾件事。
誰在跑 netpoll?
通常有兩種方式:
- 有一個(gè)專門的 M 在跑 sysmon 線程,它會(huì)定時(shí)調(diào)用 netpoll
- 當(dāng)其他 M 找不到 G 可跑時(shí),也會(huì)主動(dòng)調(diào)用 netpoll 看看有沒有 I/O 事件就緒
數(shù)據(jù)來了!
假設(shè)現(xiàn)在網(wǎng)卡收到了數(shù)據(jù),內(nèi)核把 fd=3 標(biāo)記為"可讀"。
epoll_wait 返回,告訴 Go:fd=3 有數(shù)據(jù)了!
netpoll 函數(shù)拿到這個(gè)事件,會(huì)做:
// 偽代碼
func netpoll() []*g {
events := epollwait(...)
var ready []*g
for _, ev := range events {
pd := getPollDesc(ev.fd)
if pd.waitingG != nil {
// 把這個(gè) G 加到 ready 列表
ready = append(ready, pd.waitingG)
// 把它的狀態(tài)改成 _Grunnable
pd.waitingG.status = _Grunnable
pd.waitingG = nil
}
}
return ready
}
然后,這些 ready 的 G 會(huì)被注入到某個(gè) P 的運(yùn)行隊(duì)列里。
注入到哪個(gè) P?
通常是調(diào)用 netpoll 的那個(gè) P,或者全局隊(duì)列,具體規(guī)則復(fù)雜,但核心是:這些 G 又變成了"可運(yùn)行"狀態(tài),等著被某個(gè) M 撿起來執(zhí)行。
六、G_user 被喚醒
假設(shè) G2 運(yùn)行了一段時(shí)間,或者被搶占,M1 再次調(diào)用 schedule()。這時(shí) P1 的本地隊(duì)列里有了 G_user(剛被 netpoll 放進(jìn)來的)。
M1 拿到 G_user,執(zhí)行它。
關(guān)鍵點(diǎn):G_user 從哪里開始執(zhí)行?
從 gopark() 之后的那條指令開始!因?yàn)樗?park 的時(shí)候,所有寄存器、棧指針都保存得好好的。
回到 conn.Read 的循環(huán)里:
retry:
n, err = syscall.Read(fd, p)
if err == syscall.EAGAIN {
pd.waitRead() // 上次就是在這被 park 的
goto retry // 醒來后重試
}
這次 read 成功了,數(shù)據(jù)拿到,conn.Read 返回,你的代碼繼續(xù)往下走。
七、完整流程圖
我們用 GPM 的視角重新畫一下整個(gè)流程:
【時(shí)間線】
|
| 初始: M1-P1 運(yùn)行 G_user
|
| G_user 執(zhí)行 conn.Read → 沒數(shù)據(jù) → gopark()
| → G_user 狀態(tài): _Grunning → _Gwaiting
| → G_user 從 P1 本地隊(duì)列移除,存入 pollDesc
| → M1 調(diào)用 schedule(),從 P1 隊(duì)列拿 G2 運(yùn)行
|
| 【另一邊】sysmon 線程或某個(gè)空閑 M 調(diào)用 netpoll()
| → epoll_wait 阻塞等待
| → 數(shù)據(jù)來了,內(nèi)核喚醒 epoll_wait
| → netpoll 找到 G_user,改成 _Grunnable,放回隊(duì)列
|
| M1 可能還在跑 G2,也可能被搶占
| 最終 schedule() 發(fā)現(xiàn) G_user 可運(yùn)行
| → M1 切換到 G_user
| → G_user 從 conn.Read 內(nèi)部恢復(fù),重試 read,成功拿到數(shù)據(jù)
|
| G_user 繼續(xù)往下執(zhí)行你的代碼
八、關(guān)鍵點(diǎn)總結(jié)(面試最愛問)
1. gopark 不是讓線程休眠,而是讓 goroutine 休眠
- M 會(huì)立刻找下一個(gè) G 繼續(xù)跑
- 這就是 Go 高并發(fā)的核心:I/O 不會(huì)阻塞線程,只會(huì)阻塞 goroutine
2. 一個(gè) P 對(duì)應(yīng)一個(gè)系統(tǒng)線程 M,但可以運(yùn)行成千上萬個(gè) G
- M 的數(shù)量一般等于 CPU 核心數(shù)(或略多)
- G 的數(shù)量可以遠(yuǎn)大于 M,靠的就是這種"掛起-喚醒"機(jī)制
3. netpoller 是連接 epoll 和 GPM 的橋梁
- epoll 負(fù)責(zé)監(jiān)聽 I/O 事件
- netpoller 負(fù)責(zé)把等待 I/O 的 G 和就緒的 I/O 事件配對(duì)
- 配對(duì)成功就把 G 放回隊(duì)列,讓 M 去跑
4. 從你的角度看是同步阻塞,從系統(tǒng)角度看是異步非阻塞
- 你寫的是
conn.Read(),看起來像阻塞 - 實(shí)際上內(nèi)核是非阻塞模式,Go runtime 幫你做了"阻塞時(shí)掛起,就緒時(shí)喚醒"的轉(zhuǎn)換
九、一個(gè)形象的類比
把 GPM 模型想象成一個(gè)大型餐廳的后廚:
- G 是每個(gè)廚師手里的訂單(要做的事)
- P 是廚師的工作臺(tái)(上下文)
- M 是廚師本人(線程)
- conn.Read 相當(dāng)于"等菜熟了"
廚師 M1 在處理訂單 G_user,發(fā)現(xiàn)要等菜(數(shù)據(jù)沒來):
- 他不會(huì)傻站著等,而是把 G_user 這個(gè)訂單貼到墻上(pollDesc 里)
- 然后從工作臺(tái)上拿下一個(gè)訂單 G2 繼續(xù)做
墻上有專門的人(netpoller)盯著爐子(epoll):
- 哪個(gè)菜好了,就把對(duì)應(yīng)的訂單從墻上拿下來
- 放到某個(gè)廚師的工作臺(tái)上,等廚師有空了繼續(xù)做
這樣,沒有一個(gè)廚師會(huì)閑著等菜,所有廚師永遠(yuǎn)在干活,后廚效率拉滿。
十、所以,回到最初的問題
"其實(shí)那個(gè) connection 點(diǎn) read 的時(shí)候,我們會(huì)發(fā)現(xiàn)它會(huì)檢查有沒有消息進(jìn)來。如果發(fā)現(xiàn)沒有的話,它就會(huì)最后執(zhí)行一個(gè) gopark 的函數(shù)。執(zhí)行這個(gè) gopark 之后,在 GPM 模型里面,其實(shí)就是這個(gè)時(shí)候 G 進(jìn)入了等待狀態(tài),然后 M 會(huì)臨時(shí)和這個(gè) G 解綁,去找別的 G 來運(yùn)行。"
完全正確!
這就是 Golang 并發(fā)模型的精妙之處:
- epoll 提供了 I/O 多路復(fù)用的能力
- GPM 提供了任務(wù)調(diào)度的能力
- 兩者結(jié)合,讓你可以用同步的寫法,享受異步的性能
你寫的每一行 conn.Read,背后都是 epoll 和 GPM 在默默配合,讓你"傻傻地"就能寫出高并發(fā)的網(wǎng)絡(luò)程序。