回顧
在上一篇文章中,我們講述了基本的排程流程。但是我們沒有解決如果協程內部如果存在阻塞的情況下該如何處理。比如某個G中存在對channel的收發等操作會發生阻塞,那麼這個協程就不能一直佔用M的資源,如果一直佔用可能就會導致所有M都被阻塞住了。所以我們需要把當前G暫時掛起,待阻塞返回之後重新排程這個G來執行。
所以,我們需要一種排程機制,及時釋放阻塞G佔用的資源,重新觸發一次排程器的排程邏輯,把當前G先掛起,讓其他未執行過的G來執行,從而實現資源利用率的最大化。
runtime可以攔截的阻塞
什麼是runtime可以攔截的?一般是我們在程式碼中的阻塞,大概有這幾種:
- channel生產/消費阻塞
- select
- lock
- time.sleep
- 網路讀寫
在runtime可以攔截的情況下,會先讓G進某種資料結構,待ready後重新排程G來繼續執行,阻塞期間不會繼續持有執行緒M。接下來我們以第一種情況channel為例,看看這個流程具體是如何執行的。
以channel阻塞為例
如剛才channel的那個例子所述,由於阻塞了,所以這個G需要被動的讓出所持有的P和M。我們以channel這個例子過一遍這個流程。假設有這麼一行程式碼:
ch <- 1
這是一個無緩衝的通道,此時往通道里寫入了一個值1。假設此時消費端還沒有去消費,這個時候這個通道寫入的操作就會阻塞。channel的資料結構叫做hchan:
type hchan struct {
// 通道里元素的數量
qcount uint
// 迴圈佇列的長度
dataqsiz uint
// 指標,指向儲存緩衝通道資料的迴圈佇列
buf unsafe.Pointer
// 通道中元素的大小
elemsize uint16
// 通道是否關閉的標誌
closed uint32
// 通道中元素的型別
elemtype *_type
// 已接收元素在迴圈佇列的索引
sendx uint
// 已傳送元素在迴圈佇列的索引
recvx uint
// 等待接收的協程佇列
recvq waitq
// 等待傳送的協程佇列
sendq waitq
// 互斥鎖,保護hchan的併發讀寫,下文會講
lock mutex
}
這裡我們重點關注recvq和sendq這兩個欄位。他們是一個連結串列,儲存阻塞在這個channel的傳送端和接收端的G。以上ch <- 1其實底層實現是一個chansend函式,實現如下:
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
// 嘗試從recvq,也就是接收方佇列中出隊一個元素,如果非空,則說明找到了一個正在等待的receiver,可以傳送資料過去了。傳送完資料直接return
if sg := c.recvq.dequeue(); sg != nil {
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
// 程式碼走到這裡,說明沒有接收方,需要阻塞住等待接收方(如果是無緩衝channel的話)
if !block {
unlock(&c.lock)
return false
}
// 把當前channel和G,打包生成一個sudog結構,後面會講為什麼這樣做
gp := getg()
mysg := acquireSudog()
mysg.g = gp
mysg.c = c
...
// 將sudog放到sendq中
c.sendq.enqueue(mysg)
// 呼叫gopark,這裡內部實現會講M與G解綁,並觸發一次排程迴圈
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
return true
}
我們總結一下這個chansend的流程(以無緩衝通道為例):
- 嘗試從recvq中獲取消費者
- 若recvq不空,傳送資料;若為空,則需要阻塞
- 獲取一個sudog結構,給g欄位賦值為當前G
- 把sudog掛到sendq上等待喚醒
- 呼叫gopark將M與G解綁,重新觸發一次排程,M去執行其他的G
為什麼是sudog而非G
那麼這裡為什麼要用sudog而非原始的G結構呢。答案在於,一個G可以在多個等待連結串列上。recvq和sendq都是一個waitq結構。是一個雙向連結串列。假如第一個G已經掛到了連結串列上,那麼他必然要儲存下一個G的地址,才能成功的完成雙向連結串列的邏輯,如:
type g struct {
next *g
prev *g
}
而g又可能掛在多個等待連結串列上(如select操作,一個G可能會阻塞在多個channel上),所以g裡的next和prev必然會有多個值的情況。即next和prev的地址在多個等待連結串列上的值可能是不一樣的。G和等待連結串列的關係是多對多的關係,所以這個prev和next必然不能在G上直接維護,所以我們就會將G和channel一起打包成sudog結構。它和我們MySQL中多對多的中間表設計有異曲同工之妙,相當於維護了一個g_id和channel_id:
type sudog struct {
// 原始G結構。相當於g_id
g *g
// 等待連結串列上的指標
next *sudog
prev *sudog
// 所屬的channel,相當於channel_id
c *hchan
}
最終的效果如下:
gopark
我們知道,在將sudog打包好放到sendq之後,會呼叫go_park執行阻塞邏輯。go_park內部又會呼叫park_m方法,切換到g0棧,解除M與當前G的繫結,重新觸發一次排程,讓M去繫結其他G執行:
// park continuation on g0.
func park_m(gp *g) {
_g_ := getg()
// 將G的狀態設定為waiting
casgstatus(gp, _Grunning, _Gwaiting)
// 解除M與G的繫結
dropg()
// 重新執行一次排程迴圈
schedule()
}
什麼時候喚醒
那麼問題來了,當前G已經阻塞在sendq上了,那麼誰來喚醒這個G讓他繼續執行呢?顯然是channel的接收端,在原始碼中和chansend相對的操作即chanrecv:
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
// 嘗試從sendq中拿一個等待協程出來
if sg := c.sendq.dequeue(); sg != nil {
// 如果拿到了,那麼接收資料,剛才我們的channel就屬於這種情況
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
if !block {
unlock(&c.lock)
return false, false
}
// 同上,打包成一個sudog結構,掛到recvq等待連結串列上
gp := getg()
mysg := acquireSudog()
mysg.g = gp
mysg.c = c
c.recvq.enqueue(mysg)
// 同理,拿不到sendq,呼叫gopark阻塞
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
...
return true, success
}
goready
我們看到,chanrecv和chansend邏輯大體一致,這裡就不詳細展開。由於剛才我們的sendq上有資料,那麼這裡一定會進入recv()方法接收資料。在這裡會呼叫goready()方法:
// Mark gp ready to run.
func ready(gp *g, traceskip int, next bool) {
...
status := readgstatus(gp)
// 標記G為grunnable狀態
_g_ := getg()
casgstatus(gp, _Gwaiting, _Grunnable)
// 放入runq中等待排程迴圈消費
runqput(_g_.m.p.ptr(), gp, next)
// 喚醒一個閒置的P來執行G
wakep()
releasem(mp)
}
goready和gopark是一對操作,gopark是阻塞,goready是喚醒。它會將sudog中繫結的G拿出來,傳入ready()方法,把它從gwaiting置為grunnable的狀態。並再次執行runqput。將G放到P的本地佇列/全域性佇列上等待排程迴圈來消費。這樣整個流程就能跑起來了。
總結一下,
- sender呼叫gopark掛起,一定是由receiver(或close)通過goready喚醒
- receiver呼叫gopark掛起,一定是由sender(或close)通過goready喚醒
runtime不能攔截的阻塞
什麼是runtime不能攔截的?即CGO程式碼和系統呼叫。CGO這裡先不講,由於系統呼叫這裡也有可能發生阻塞,且不屬於runtime層面的阻塞,runtime也不能讓G進某一個相關資料結構,runtime無法捕獲到。
那麼這個時候就需要一個後臺監控的特殊執行緒sysmon來監控這種情況。它會定期迴圈不斷的執行。它會申請一個單獨的M,且不需要繫結P就可以執行,優先順序最高。
sysmon的核心是sysmon()方法。監控會在迴圈中呼叫retake()方法搶佔處於長時間阻塞中的P,該函式會遍歷執行時的所有P。retake()的實現如下:
func retake(now int64) uint32 {
n := 0
for i := 0; i < len(allp); i++ {
_p_ := allp[i]
pd := &_p_.sysmontick
s := _p_.status
//當處理器處於_Prunning或者_Psyscall狀態時,如果上一次觸發排程的時間已經過去了10ms,我們會呼叫preemptone()搶佔當前P
if s == _Prunning || s == _Psyscall {
t := int64(_p_.schedtick)
if pd.schedwhen+forcePreemptNS <= now {
preemptone(_p_)
}
}
// 當處理器處系統呼叫阻塞狀態時,當處理器的執行佇列不為空或者不存在空閒P時,或者當系統呼叫時間超過了10ms,會呼叫handoffp將P從M上剝離
if s == _Psyscall {
if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
continue
}
if atomic.Cas(&_p_.status, s, _Pidle) {
n++
_p_.syscalltick++
handoffp(_p_)
}
}
}
return uint32(n)
}
sysmon通過在後臺監控迴圈中搶佔P,來避免同一個G佔用M太長時間造成長時間阻塞及飢餓問題。
關注我們
歡迎對本系列文章感興趣的讀者訂閱我們的公眾號,關注博主下次不迷路~