Go協(xié)程的底層原理及分析
為什么要有協(xié)程
什么是進(jìn)程

- 操作系統(tǒng)“程序”的最小單位
- 進(jìn)程用來(lái)占用內(nèi)存空間
- 進(jìn)程相當(dāng)于廠房,占用工廠空間
什么是線程
進(jìn)程如果比作廠房,線程就是廠房里面的生產(chǎn)線:

- 每個(gè)進(jìn)程可以有多個(gè)線程
- 線程使用系統(tǒng)分配給進(jìn)程的內(nèi)存,線程之間共享內(nèi)存
CPU在線程之間來(lái)回切換:

- 線程用來(lái)占用CPU時(shí)間
- 線程的調(diào)度需要由系統(tǒng)進(jìn)行,開銷較大
- 線程相當(dāng)于工廠的生產(chǎn)線,占用工人的工時(shí)
- 線程里跑的程序就是生產(chǎn)流程
線程的問題
- 線程本身占用資源大
- 線程的操作開銷大
- 線程切換開銷大
什么是協(xié)程
通過將程序的運(yùn)行狀態(tài)打包,使其可以在線程中調(diào)度運(yùn)行多個(gè)程序:

- 協(xié)程就是將一段程序的運(yùn)行狀態(tài)打包,可以在線程之間調(diào)度
- 將生產(chǎn)流程打包,使得流程不固定在生產(chǎn)線上
- 協(xié)程并不取代線程,協(xié)程也需要在線程上運(yùn)行
- 線程是協(xié)程的資源,協(xié)程使用線程這個(gè)資源
協(xié)程的優(yōu)勢(shì)
- 資源利用率高
- 快速調(diào)度
- 超高并發(fā)
總結(jié)
- 進(jìn)程用分配內(nèi)存空間
- 線程用來(lái)分配cpu時(shí)間
- 協(xié)程用來(lái)精細(xì)利用線程
- 協(xié)程的本質(zhì)是一段包含了運(yùn)行狀態(tài)的程序
協(xié)程的本質(zhì)
func do1() {
func2()
}
func do2() {
func3() // 在這里打個(gè)斷點(diǎn)
}
func do3() {
fmt.Print("dododo")
}
func main() {
go func1()
time.Sleep(time.Minute)
}
運(yùn)行調(diào)試,打開debugger,看輸出的棧信息,協(xié)程從do1調(diào)用到do2,接下來(lái)還會(huì)調(diào)用do3,但是由于打了斷點(diǎn),所以do3還沒有調(diào)用進(jìn)來(lái):

協(xié)程在go語(yǔ)言的內(nèi)部結(jié)構(gòu):g結(jié)構(gòu)體,路徑runtime/runtime2.go:
type g struct {
stack stack // offset known to runtime/cgo
}
g結(jié)構(gòu)體中的stack就是協(xié)程棧,這個(gè)協(xié)程棧就是我們上面調(diào)試輸出看到的棧信息。進(jìn)入stack結(jié)構(gòu)體,有兩個(gè)指針參數(shù):
type stack struct {
lo uintptr // 棧的上限
hi uintptr // 棧的下限
}
姑且先認(rèn)為這個(gè)棧有兩個(gè)指針,一個(gè)高指針,一個(gè)低指針,它是指向了我們棧在內(nèi)存的棧區(qū)里面占用的這塊內(nèi)存,用來(lái)存儲(chǔ)我們的棧幀信息。繼續(xù)看g結(jié)構(gòu)體里面的其它重要參數(shù):
type g struct {
stack stack // offset known to runtime/cgo
sched gobuf
}
還有一個(gè)叫sched的成員,類型是gobuf結(jié)構(gòu)體,進(jìn)入gobuf結(jié)構(gòu)體,里面有兩個(gè)重要的成員,一個(gè)是sp,stack pointer 棧指針,指向當(dāng)前運(yùn)行到的do2這個(gè)棧幀;還有一個(gè)是pc,program counter 程序計(jì)數(shù)器 記錄當(dāng)前程序運(yùn)行到了哪行代碼:
type gobuf struct {
sp uintptr
pc uintptr
g guintptr
ctxt unsafe.Pointer
ret uintptr
lr uintptr
bp uintptr // for framepointer-enabled architectures
}
再回過頭來(lái)看g結(jié)構(gòu)體還有哪些重要成員,atomicstatus,這個(gè)協(xié)程的狀態(tài);goid 協(xié)程的id號(hào):
type g struct {
stack stack // offset known to runtime/cgo
sched gobuf
atomicstatus uint32
goid int64
}
根據(jù)上面的分析,繪出協(xié)程的底層結(jié)構(gòu),協(xié)程的底層結(jié)構(gòu)是一個(gè)g結(jié)構(gòu)體,它里面有很多個(gè)變量,一個(gè)是stack,表示的是協(xié)程棧,指向的是stack結(jié)構(gòu)體,stack里面有兩個(gè)重要元素,一個(gè)是lo,指向的是協(xié)程棧的低地址,還有一個(gè)是hi,指向的是協(xié)程棧的高地址,這個(gè)棧是為了記錄現(xiàn)在這個(gè)協(xié)程執(zhí)行到哪里了。g結(jié)構(gòu)體里面還有個(gè)sched的字段,這個(gè)字段是個(gè)gobuf結(jié)構(gòu)體,這個(gè)結(jié)構(gòu)體有兩個(gè)核心的字段,一個(gè)是sp,叫棧指針,指向的是當(dāng)前運(yùn)行到的棧幀(目前是do2方法),還有一個(gè)是pc,叫程序計(jì)數(shù)器,記錄當(dāng)前程序運(yùn)行到了do2中的哪行代碼:

- runtime中,協(xié)程的本質(zhì)是一個(gè)g結(jié)構(gòu)體
- stack:堆棧地址
- gobuf:目前程序運(yùn)行現(xiàn)場(chǎng)
- atomicstatus:協(xié)程狀態(tài)
協(xié)程的結(jié)構(gòu)我們知道了,但協(xié)程不是還要放到線程上面去執(zhí)行,那go底層是如何表示線程的底層結(jié)構(gòu)的呢,在runtime/runtime2.go中還有一個(gè)m的結(jié)構(gòu)體,這個(gè)結(jié)構(gòu)體就是用來(lái)記錄操作系統(tǒng)線程的信息:
type m struct {
g0 *g // 調(diào)度協(xié)程而產(chǎn)生的協(xié)程
curg *g // 當(dāng)前運(yùn)行的協(xié)程
id int64 // 線程的id
mOS // 記錄不同操作系統(tǒng)底層對(duì)線程的額外描述信息
}
- runtime中將操作系統(tǒng)線程抽象為m結(jié)構(gòu)體
- g0:g0協(xié)程,操作調(diào)度器
- curg:current g,目前線程運(yùn)行的g
- mOS:操作系統(tǒng)線程信息
協(xié)程是如何執(zhí)行的
單線程循環(huán)(Go 0.x)
每個(gè)go里面的線程會(huì)從schedule()方法開始運(yùn)行,schedule()方法是從g0棧上開始執(zhí)行的,g0棧就是給g0這個(gè)協(xié)程在??臻g里面分配的一段內(nèi)存地址,用來(lái)記錄你的函數(shù)調(diào)用跳轉(zhuǎn)的信息,為什么不用一個(gè)普通協(xié)程的棧記錄呢,有兩個(gè)原因:1.普通協(xié)程的棧只能記錄業(yè)務(wù)方法的函數(shù)跳轉(zhuǎn)調(diào)用信息2.當(dāng)我們這個(gè)線程還沒有拿到協(xié)程去運(yùn)行的時(shí)候,是沒有普通協(xié)程棧的。所以線程循環(huán)一開始使用的就是g0這個(gè)棧,可以理解為在內(nèi)存的棧區(qū)里面有單獨(dú)的一塊區(qū)域用來(lái)記錄線程所執(zhí)行的方法。

這個(gè)schedule()的方法的路徑是runtime/proc.go,下面簡(jiǎn)單分析下這個(gè)方法:
func schedule() {
var gp *g // 新建了一個(gè)變量叫g(shù)p,就是即將要運(yùn)行的協(xié)程
... // 在全局的各種隊(duì)列或本地的各種隊(duì)列嘗試去拿一個(gè)可以運(yùn)行的協(xié)程
execute(gp, inheritTime) // 調(diào)用了execute方法
}
繼續(xù)看execute方法的邏輯:
func execute(gp *g, inheritTime bool) {
... // 給即將要執(zhí)行的gp協(xié)程里面的一些字段賦了值
gogo(&gp.sched) // 調(diào)用了gogo方法
}
繼續(xù)看gogo方法,發(fā)現(xiàn)只有一個(gè)函數(shù)聲明:
func gogo(buf *gobuf)
憑經(jīng)驗(yàn)可以判斷出gogo方法是由匯編實(shí)現(xiàn)的,我們用Ctrl+Shift+F全局搜索這個(gè)gogo方法,發(fā)現(xiàn)每個(gè)平臺(tái)都實(shí)現(xiàn)了對(duì)應(yīng)的gogo方法,說明這個(gè)方法的邏輯是平臺(tái)相關(guān)的,我們找一個(gè)runtime/asm_amd64.s文件下的gogo方法來(lái)分析:
// func gogo(buf *gobuf) // restore state from Gobuf; longjmp TEXT runtime·gogo(SB), NOSPLIT, $0-8 MOVQ buf+0(FP), BX // gobuf MOVQ gobuf_g(BX), DX MOVQ 0(DX), CX // make sure g != nil JMP gogo<>(SB)
入?yún)髁艘粋€(gè)gobuf指針,回顧下,gobuf結(jié)構(gòu)體有兩個(gè)核心的字段,一個(gè)是sp,叫棧指針,指向的是當(dāng)前運(yùn)行到的棧幀(目前是do2方法),還有一個(gè)是pc,叫程序計(jì)數(shù)器,記錄當(dāng)前程序運(yùn)行到了do2中的哪行代碼。繼續(xù)往下看,它最后又跳轉(zhuǎn)到了下面這個(gè)gogo方法,這個(gè)gogo方法里面有兩個(gè)比較核心的地方:
TEXT gogo<>(SB), NOSPLIT, $0 get_tls(CX) MOVQ DX, g(CX) MOVQ DX, R14 // set the g register MOVQ gobuf_sp(BX), SP // 人為插入了一個(gè)goexit方法的棧幀 MOVQ gobuf_ret(BX), AX MOVQ gobuf_ctxt(BX), DX MOVQ gobuf_bp(BX), BP MOVQ $0, gobuf_sp(BX) // clear to help garbage collector MOVQ $0, gobuf_ret(BX) MOVQ $0, gobuf_ctxt(BX) MOVQ $0, gobuf_bp(BX) MOVQ gobuf_pc(BX), BX // 將我們協(xié)程運(yùn)行到了哪行代碼的位置取出來(lái) JMP BX // 跳轉(zhuǎn)到那個(gè)位置進(jìn)行執(zhí)行
MOVQ gobuf_sp(BX), SP:往我們的協(xié)程棧里面插入了一個(gè)棧幀,就是goexit這個(gè)方法。也就是從gogo這個(gè)方法第一次拿到了我們的普通協(xié)程棧,通過gobuf這個(gè)結(jié)構(gòu)體里面指示的協(xié)程棧的高地址和低地址,它就知道了我們協(xié)程棧的范圍,拿到這個(gè)協(xié)程棧后它首先將goexit這個(gè)方法的棧幀插入了進(jìn)來(lái)。
MOVQ gobuf_pc(BX), BX JMP BX`:跳轉(zhuǎn)到我們現(xiàn)場(chǎng)正在執(zhí)行的程序計(jì)數(shù)器。
分析后邏輯就很清晰了,首先給我們的協(xié)程棧插入了一個(gè)goexit的棧幀,然后跳轉(zhuǎn)到我們g結(jié)構(gòu)體里面的gobuf里面的程序計(jì)數(shù)器的那一行進(jìn)行執(zhí)行。執(zhí)行就是進(jìn)入到了業(yè)務(wù)方法里面,它從do1開始執(zhí)行,do1調(diào)到do2然后調(diào)到do3,它就這樣執(zhí)行。執(zhí)行的時(shí)候用的是我們的g stack,也就是用的我們協(xié)程自己的協(xié)程棧,用自己的協(xié)程棧是為了每個(gè)g結(jié)構(gòu)體記錄自己的執(zhí)行現(xiàn)場(chǎng),記錄自己執(zhí)行到了哪個(gè)位置。然后看我們的業(yè)務(wù)代碼,執(zhí)行到do3打印了一條文本,然后do3要退后do2,do2要退回do1,然后最終會(huì)退到goexit這個(gè)方法里面。
看下goexit這個(gè)方法的邏輯,雙擊shift,打開在文件中查找runtime.goexit,發(fā)現(xiàn)只有聲明,說明是由匯編語(yǔ)言編寫的:
func goexit(neverCallThisFunction)
每個(gè)平臺(tái)都實(shí)現(xiàn)了對(duì)應(yīng)的goexit方法,說明這個(gè)方法的邏輯是平臺(tái)相關(guān)的,我們找一個(gè)runtime/asm_amd64.s文件下的goexit方法來(lái)分析:
// The top-most function running on a goroutine // returns to goexit+PCQuantum. TEXT runtime·goexit(SB),NOSPLIT|TOPFRAME,$0-0 BYTE $0x90 // NOP CALL runtime·goexit1(SB) // does not return // traceback from goexit1 must hit code range of goexit BYTE $0x90 // NOP
里面比較核心的是CALL runtime·goexit1(SB)這行,它調(diào)用了runtime·goexit1這個(gè)go方法,看下這個(gè)方法的邏輯,位于runtime/proc.go中:
// Finishes execution of the current goroutine.
func goexit1() {
mcall(goexit0)
}
看下mcall這個(gè)方法的定義,它會(huì)切換到g0棧上執(zhí)行里面的方法,后面的函數(shù)調(diào)用關(guān)系就用g0棧來(lái)記錄:
// mcall switches from the g to the g0 stack and invokes fn(g) func mcall(fn func(*g))
繼續(xù)看下goexit0這個(gè)方法,里面最終會(huì)重新回到schedule()方法:
// goexit continuation on g0.
func goexit0(gp *g) {
... // 設(shè)置協(xié)程中的各項(xiàng)參數(shù)
schedule()
}
執(zhí)行g(shù)o里面的業(yè)務(wù)邏輯或業(yè)務(wù)代碼都是在線程上執(zhí)行的,只是說線程里面在執(zhí)行一個(gè)循環(huán),在業(yè)務(wù)方法之外也就是我們寫的協(xié)程邏輯之外它是用了g0棧來(lái)記錄了函數(shù)調(diào)用和跳轉(zhuǎn)的關(guān)系,進(jìn)入到業(yè)務(wù)方法之后,就我們寫的do1調(diào)到do2然后調(diào)到do3,它使用協(xié)程自己的棧來(lái)記錄函數(shù)調(diào)用和跳轉(zhuǎn)的關(guān)系,這個(gè)棧里面還記錄了本地臨時(shí)變量,業(yè)務(wù)方法執(zhí)行完成后會(huì)回退到人為強(qiáng)行插入的goexit這個(gè)棧幀,進(jìn)入這個(gè)棧幀就會(huì)調(diào)用goexit0這個(gè)方法,他會(huì)切換到schedule,不斷往復(fù)循環(huán)。

用一個(gè)更抽象的圖來(lái)表示這種過程,M用來(lái)表示系統(tǒng)線程的信息,G表示協(xié)程,有一個(gè)協(xié)程隊(duì)列或協(xié)程池子,線程就會(huì)一個(gè)個(gè)將協(xié)程從隊(duì)列里面取出來(lái)執(zhí)行,執(zhí)行完了后就開始尋找下一個(gè)進(jìn)行執(zhí)行,不斷循環(huán)。這么一個(gè)單線程循環(huán)的邏輯是在0點(diǎn)幾版的go里面實(shí)現(xiàn)的,當(dāng)時(shí)go還沒有正式發(fā)布。
多線程循環(huán)(Go 1.0)
現(xiàn)在的cpu都是多核多線程,應(yīng)用也是多線程的應(yīng)用,如果用上面的go單線程版本那也太浪費(fèi)系統(tǒng)資源了,所以go從1.0開始引入多線程循環(huán)。以下面的兩個(gè)線程為例,這兩個(gè)線程執(zhí)行的是一模一樣的邏輯,就是走線程循環(huán)。它們會(huì)不斷地從協(xié)程隊(duì)列里面獲取可執(zhí)行協(xié)程拿過來(lái)然后送入到gogo里面去執(zhí)行g(shù)協(xié)程里面的業(yè)務(wù)方法,然后再退出,退出后再?gòu)年?duì)列里面拿,循環(huán)往復(fù)。如果有8個(gè)線程的話就有8個(gè)這樣的循環(huán),不斷從協(xié)程隊(duì)列里面抓取可執(zhí)行的協(xié)程。

那這樣就帶來(lái)一個(gè)問題,所有線程都從全局的協(xié)程隊(duì)列里面去獲取,就有并發(fā)問題。要保證這個(gè)協(xié)程只被抓到一個(gè)線程上面去執(zhí)行,執(zhí)行完成后就被丟棄,所以這個(gè)全局的協(xié)程隊(duì)列需要加鎖,不然業(yè)務(wù)就會(huì)產(chǎn)生沖突。

用一個(gè)更抽象的圖來(lái)表示這種過程,M用來(lái)表示系統(tǒng)線程,G表示協(xié)程,下面還有一個(gè)全局的隊(duì)列,每個(gè)線程都要從全局的隊(duì)列里面去抓取協(xié)程執(zhí)行,執(zhí)行完了后丟棄,再抓取一個(gè)新的,這樣就會(huì)有并發(fā)問題,所以全局的協(xié)程隊(duì)列需要加鎖,三個(gè)線程或更多線程就要競(jìng)爭(zhēng)這一把鎖,競(jìng)爭(zhēng)成功了就能拿到一個(gè)協(xié)程去執(zhí)行:

總結(jié)
線程循環(huán):
- 操作系統(tǒng)并不知道Goroutine的存在
- 操作系統(tǒng)線程執(zhí)行一個(gè)調(diào)度循環(huán),順序執(zhí)行Goroutine
- 調(diào)度循環(huán)非常像線程池
問題:
- 協(xié)程順序執(zhí)行,無(wú)法并發(fā)
- 多線程并發(fā)時(shí),會(huì)搶奪協(xié)程隊(duì)列的全局鎖
總結(jié):
- 協(xié)程的本質(zhì)時(shí)一個(gè)g結(jié)構(gòu)體
- g結(jié)構(gòu)體記錄了協(xié)程棧、PC信息
- 最簡(jiǎn)情況下,線程執(zhí)行標(biāo)準(zhǔn)調(diào)度循環(huán),執(zhí)行協(xié)程
G-M-P調(diào)度模型
本地隊(duì)列

線程由原來(lái)一次只從全局隊(duì)列拿一個(gè)協(xié)程轉(zhuǎn)變?yōu)橐淮文靡慌鷧f(xié)程,先抓取一批放到本地隊(duì)列里面執(zhí)行,將本地隊(duì)列里的協(xié)程執(zhí)行完后再拿下一批,而從降低全局鎖的沖突。
這個(gè)隊(duì)列在go底層是如何表示的呢,和前面的g一樣,也是一個(gè)p結(jié)構(gòu)體,路徑是runtime/runtime2.go,來(lái)看下它里面的重要參數(shù):
type p struct {
m muintptr // 指向它服務(wù)的那個(gè)線程
// 它是可執(zhí)行的協(xié)程的隊(duì)列,可以無(wú)鎖進(jìn)行訪問
runqhead uint32 // 隊(duì)列的頭
runqtail uint32 // 隊(duì)列的尾
runq [256]guintptr // 256長(zhǎng)度的指針,每個(gè)指針會(huì)指向一個(gè)g結(jié)構(gòu)體
runnext guintptr // 指向下一個(gè)可用的協(xié)程的指針
}
根據(jù)上面的分析,用一個(gè)圖來(lái)表示p結(jié)構(gòu)體,m指向它所要服務(wù)的線程,runq說明它是一個(gè)可容納256個(gè)可執(zhí)行協(xié)程的隊(duì)列,runqhead指向的是隊(duì)列的頭,runqtail指向的是隊(duì)列的尾,還有runnext指向下一個(gè)要執(zhí)行的協(xié)程的地址。

G-M-P模型
根據(jù)前面對(duì)p結(jié)構(gòu)體的分析,結(jié)合前面的G和M,就湊夠了G-M-P模型,每個(gè)P服務(wù)于一個(gè)M,P的職責(zé)是它要構(gòu)建一個(gè)本地的協(xié)程隊(duì)列,M每次要獲取一個(gè)協(xié)程就直接從本地隊(duì)列獲取,如果M將它本地隊(duì)列里的協(xié)程都執(zhí)行完了,那就只能從全局隊(duì)列里獲取,先搶到鎖,然后再拿一批到本地隊(duì)列,M就又可以無(wú)鎖地執(zhí)行協(xié)程了。這就是最樸素最簡(jiǎn)單的G-M-P模型。

進(jìn)一步對(duì)P的作用及底層邏輯進(jìn)行分析
竊取式工作分配機(jī)制
- M與G之間的中介(送料器)
- P持有一些G,使得每次獲取G的時(shí)候不用從全局找
- 大大減少了并發(fā)沖突的情況
那p這部分的邏輯在go源碼哪個(gè)位置體現(xiàn)的呢,回到前面多次提到的schedule方法,方法的路徑是runtime/proc.go,他是我們線程循環(huán)的第一個(gè)方法,之前分析的時(shí)候忽略了里面大量的邏輯,其中一個(gè)邏輯就是gp,它要執(zhí)行的這個(gè)協(xié)程是如何獲取的:
func schedule() {
var gp *g // 新建了一個(gè)變量叫g(shù)p,就是即將要運(yùn)行的協(xié)程
if gp == nil {
gp, inheritTime = runqget(_g_.m.p.ptr())
}
}
如果前面沒有拿到要執(zhí)行的這個(gè)協(xié)程,就執(zhí)行了一個(gè)runqget的方法,意思是通過可執(zhí)行隊(duì)列獲取一個(gè)協(xié)程,先看下它的入?yún)ⅲ?code>_g_是我們現(xiàn)在正在運(yùn)行的這個(gè)協(xié)程,也就是還沒有切換之前,這個(gè)線程正在跑的這個(gè)協(xié)程,.m就到了我們現(xiàn)在的這個(gè)線程的結(jié)構(gòu)體,.p就到了我們現(xiàn)在這個(gè)線程對(duì)應(yīng)的本地隊(duì)列,然后取了它的指針。傳入到了runqget中??聪?code>runqget的邏輯,正常情況下next是runnext,runnext指向的是下一個(gè)可執(zhí)行的協(xié)程,這里將這個(gè)地址取了出來(lái)然后return了回去:
func runqget(_p_ *p) (gp *g, inheritTime bool) {
next := _p_.runnext
if next != 0 && _p_.runnext.cas(next, 0) {
return next.ptr(), true
}
}
如果本地的隊(duì)列沒有了,那要從全局的隊(duì)列獲取一批,這個(gè)邏輯在哪里呢,回到schedule方法,繼續(xù)往下看:
func schedule() {
var gp *g // 新建了一個(gè)變量叫g(shù)p,就是即將要運(yùn)行的協(xié)程
if gp == nil {
gp, inheritTime = runqget(_g_.m.p.ptr())
}
if gp == nil {
gp, inheritTime = findrunnable()
}
}
如果從本地的隊(duì)列沒有取到可執(zhí)行的協(xié)程,它會(huì)執(zhí)行findrunnable方法:
func findrunnable() (gp *g, inheritTime bool) {
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(_p_, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false
}
}
}
findrunnable里面如果確實(shí)從本地隊(duì)列獲取不到可執(zhí)行的協(xié)程,它就會(huì)執(zhí)行globrunqget方法,嘗試從全局隊(duì)列獲?。?/p>
// 從全局的協(xié)程隊(duì)列里拿一批協(xié)程
func globrunqget(_p_ *p, max int32) *g {
}
再往回退,如果本地隊(duì)列和全局隊(duì)列都沒有獲取到可執(zhí)行的協(xié)程,那是不是線程就閑著,什么也不干了,當(dāng)然不是,它還能從別的地方拿到,從findrunnable中調(diào)用globrunqget方法的位置往下翻,找到下面這行代碼:
gp, inheritTime, tnow, w, newWork := stealWork(now)
點(diǎn)擊跳轉(zhuǎn)到stealWork這個(gè)方法,查看注釋,意思是stealWork它的作用是從別的隊(duì)列上偷一些協(xié)程過來(lái):
// stealWork attempts to steal a runnable goroutine or timer from any P
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
}
本地也沒有,全局也沒有,就只能從別的p上偷一些協(xié)程過來(lái),進(jìn)行任務(wù)竊?。?/p>

左邊這個(gè)線程的本地和全局都沒有協(xié)程可獲取,但是隔壁的線程對(duì)應(yīng)的隊(duì)列上還有,它就會(huì)偷一些過來(lái)執(zhí)行,幫助隔壁的線程分擔(dān)工作壓力:

竊取式工作分配機(jī)制:
- 如果在本地或者全局隊(duì)列中都找不到G
- 去別的P中“偷”
- 增強(qiáng)了線程的利用率
新建協(xié)程的放置
- 隨機(jī)尋找一個(gè)P
- 將新協(xié)程放入P的runnect(插隊(duì))
- 若P本地隊(duì)列滿,放入全局隊(duì)列
go會(huì)認(rèn)為你新建的這個(gè)協(xié)程優(yōu)先級(jí)是最高的,盡量往前排,優(yōu)先執(zhí)行。這一部分的代碼邏輯是在runtime/proc.go的newproc方法中,這個(gè)方法就是用來(lái)新建協(xié)程:
func newproc(fn *funcval) {
newg := newproc1(fn, gp, pc) // 新建一個(gè)協(xié)程
_p_ := getg().m.p.ptr()
runqput(_p_, newg, true) // 尋找p,放到本地隊(duì)列或全局隊(duì)列
}
尋找p,放到本地隊(duì)列或全局隊(duì)列的邏輯都在runqput方法里面,有興趣的可以下去分析下。
如何實(shí)現(xiàn)協(xié)程并發(fā)
上面的G-M-P調(diào)度模型解決了多線程并發(fā)時(shí),會(huì)搶奪協(xié)程隊(duì)列的全局鎖問題,那協(xié)程順序執(zhí)行,無(wú)法并發(fā)的問題又是如何處理的呢。
協(xié)程饑餓問題
兩個(gè)線程正在運(yùn)行的協(xié)程的執(zhí)行時(shí)間特別長(zhǎng),會(huì)導(dǎo)致后面對(duì)時(shí)間敏感的協(xié)程無(wú)法得到切換執(zhí)行,造成異常。

如何解決呢,回到之前的單線程循環(huán)模型,如果在執(zhí)行超長(zhǎng)協(xié)程或業(yè)務(wù)的時(shí)候,引入某種輪換機(jī)制,超長(zhǎng)協(xié)程執(zhí)行一段時(shí)間后將其暫停,切換執(zhí)行一下后面的協(xié)程,以防后面有一些時(shí)間敏感的協(xié)程得不到執(zhí)行會(huì)導(dǎo)致異常。

所以這種超大協(xié)程,執(zhí)行到業(yè)務(wù)輪換點(diǎn)時(shí),保存現(xiàn)場(chǎng),將執(zhí)行中的業(yè)務(wù)數(shù)據(jù)和代碼執(zhí)行的行數(shù),保存到自己的協(xié)程結(jié)構(gòu)體中,放回隊(duì)列或休眠,讓出線程從隊(duì)列取一個(gè)優(yōu)先級(jí)較高的協(xié)程進(jìn)行執(zhí)行。

用下面一個(gè)更抽象的圖來(lái)描述,那就是通過本地隊(duì)列的小循環(huán)來(lái)解決本地隊(duì)列的協(xié)程饑餓問題。

又來(lái)了一個(gè)新問題,如果本地隊(duì)列的協(xié)程循環(huán)了很多次還沒有執(zhí)行完,勢(shì)必會(huì)造成全局隊(duì)列的協(xié)程饑餓。所以在本地小循環(huán)的時(shí)候,會(huì)在適當(dāng)?shù)臅r(shí)候從全局隊(duì)列中拿一些上來(lái)進(jìn)行執(zhí)行,也參與一下本地的小循環(huán),這樣就可以解決全局的協(xié)程隊(duì)列得不到運(yùn)行的問題。

回到之前的schduler方法,我們來(lái)看下,全局隊(duì)列大循環(huán)的邏輯在哪里體現(xiàn)的,找到下面這行代碼,意思是每執(zhí)行61次線程循環(huán),會(huì)從全局的協(xié)程隊(duì)列中拿一個(gè)進(jìn)我們的本地隊(duì)列:
func schedule() {
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)
unlock(&sched.lock)
}
}
切換的時(shí)機(jī)
超大協(xié)程的業(yè)務(wù)輪換點(diǎn)是如何判斷的呢,會(huì)在什么情況下進(jìn)行一個(gè)切換:
- 主動(dòng)掛起(runtime.gopark)
- 系統(tǒng)調(diào)用完成時(shí)
主動(dòng)掛起(runtime.gopark)

業(yè)務(wù)方法中如果調(diào)用了gopark()方法,會(huì)直接從線程循環(huán)中跳轉(zhuǎn)到線程循環(huán)的開頭,執(zhí)行schedule()方法,重新從協(xié)程隊(duì)列取協(xié)程進(jìn)行執(zhí)行,這樣就可以解決后面小的任務(wù)饑餓的問題,這個(gè)方法在runtime/proc.go中:
// 讓我們現(xiàn)在正在運(yùn)行的這個(gè)協(xié)程進(jìn)入等待狀態(tài)
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
......
mcall(park_m)
}
主要的邏輯就是維護(hù)了我們的協(xié)程和m的一些狀態(tài),最后用mcall調(diào)用了park_m這個(gè)方法,mcall的調(diào)用之前講過,它會(huì)切換到g0棧上執(zhí)行里面的方法,后面的函數(shù)調(diào)用關(guān)系就用g0棧來(lái)記錄。接著看下park_m這個(gè)方法:
// park continuation on g0.
func park_m(gp *g) {
......
schedule()
}
這個(gè)方法主要就是進(jìn)行了一些狀態(tài)的維護(hù)和一些邏輯檢查,最重要的是后面調(diào)用了schedule這個(gè)方法,回到了線程循環(huán)的起始點(diǎn)。
關(guān)于gopark這個(gè)方法還有兩點(diǎn)需要注意,那就是這個(gè)方法是小寫開頭的,說明我們自己是調(diào)用不了的,調(diào)用不了為什么還能叫做主動(dòng)掛起呢,雖然我們自己調(diào)用不了,但是我們?cè)跇I(yè)務(wù)中使用的一些邏輯會(huì)主動(dòng)調(diào)用gopark,例如涉及到鎖、channel、sleep。第二點(diǎn)就是調(diào)用了gopark這個(gè)方法后,我們的協(xié)程會(huì)進(jìn)入waiting狀態(tài),是沒有辦法被再次立即調(diào)用的,例如sleep只有當(dāng)sleep結(jié)束后,系統(tǒng)邏輯會(huì)自動(dòng)地將協(xié)程的狀態(tài)改為operable,這樣我們的調(diào)度器會(huì)再次調(diào)度這個(gè)協(xié)程。
系統(tǒng)調(diào)用完成時(shí)

我們的業(yè)務(wù)程序不可避免會(huì)做一些系統(tǒng)調(diào)用,比如說檢查網(wǎng)絡(luò)狀態(tài)、硬件狀態(tài),這類系統(tǒng)底層命令的調(diào)用,一旦有這種系統(tǒng)調(diào)用,在完成后,我們的協(xié)程會(huì)在exitsyscall()方法中進(jìn)行協(xié)程切換。關(guān)于exitsyscall這個(gè)方法也在runtime/proc.go中:
//go:nosplit
//go:nowritebarrierrec
//go:linkname exitsyscall
func exitsyscall() {
......
Gosched()
......
}
它會(huì)在里面進(jìn)入一個(gè)Gosched的方法,會(huì)在里面用mcall去調(diào)用gosched_m:
func Gosched() {
checkTimeouts()
mcall(gosched_m)
}
gosched_m里面會(huì)調(diào)用goschedImpl方法:
func gosched_m(gp *g) {
if trace.enabled {
traceGoSched()
}
goschedImpl(gp)
}
goschedImpl這個(gè)方法里面就再次調(diào)用了schedule這個(gè)方法,回到了線程循環(huán)的起始點(diǎn)
func goschedImpl(gp *g) {
......
schedule()
}
關(guān)于系統(tǒng)調(diào)用我們不用特別在意,只要知道涉及到系統(tǒng)調(diào)用,他會(huì)進(jìn)入到exitsyscall方法,我們的線程就會(huì)停止這個(gè)協(xié)程,返回到線程循環(huán)的起始位置,執(zhí)行別的協(xié)程。至于為什么會(huì)這樣,其實(shí)也沒有別的什么原因,就是想讓它在執(zhí)行完系統(tǒng)調(diào)用后找一個(gè)時(shí)機(jī)切換下,防止它永遠(yuǎn)不切換。
總結(jié)
- 如果協(xié)程順序執(zhí)行,會(huì)有饑餓問題
- 協(xié)程執(zhí)行中間,將協(xié)程掛起,執(zhí)行其它協(xié)程
- 完成系統(tǒng)調(diào)用時(shí)掛起,也可以主動(dòng)掛起
- 防止全局隊(duì)列饑餓,本地隊(duì)列隨機(jī)抽取全局隊(duì)列
搶占式調(diào)度
如果有一個(gè)超大業(yè)務(wù)的協(xié)程,既不主動(dòng)掛起也不系統(tǒng)調(diào)用,那這種情況下就勢(shì)必會(huì)引發(fā)其它協(xié)程的饑餓問題,這該怎么辦?
基于協(xié)作的搶占式調(diào)度
思路
有沒有一個(gè)地方,經(jīng)常會(huì)被調(diào)用?能不能在這個(gè)地方做一些工作呢,是有的,這個(gè)方法叫做runtime.morestack_noctxt
用之前介紹協(xié)程的本質(zhì)的這個(gè)例子,用匯編看下,將其編出來(lái)后,有沒有什么特點(diǎn)。
func do1() {
func2()
}
func do2() {
func3() // 在這里打個(gè)斷點(diǎn)
}
func do3() {
fmt.Print("dododo")
}
func main() {
go func1()
time.Sleep(time.Minute)
}
go build -gcflags -S main.go上面的代碼,一個(gè)個(gè)看這些方法編出來(lái)后的內(nèi)容,do1在調(diào)用do2之前會(huì)調(diào)用runtime.morestack_noctxt方法,do2在調(diào)用do3之前也會(huì)調(diào)用runtime.morestack_noctxt方法,只要我們的方法中有調(diào)用其它的方法,編譯器在編譯的時(shí)候都會(huì)給它插入一個(gè)runtime.morestack_noctxt方法,也就是在調(diào)用其它方法之前,都要調(diào)用一下runtime.morestack_noctxt方法。

runtime.morestack_noctxt
runtime.morestack_noctxt的本意是檢查協(xié)程棧是否有足夠空間,如果沒有足夠的空間會(huì)進(jìn)行擴(kuò)容操作,這個(gè)業(yè)務(wù)本身與我們的協(xié)程調(diào)度沒有任何關(guān)系。既然每次業(yè)務(wù)在函數(shù)調(diào)用時(shí),總會(huì)被編譯器插入這個(gè)方法,我們就可以在這個(gè)方法里面下個(gè)鉤子。
標(biāo)記搶占
這個(gè)鉤子就是標(biāo)記搶占,怎么個(gè)標(biāo)記搶占法呢,就是當(dāng)系統(tǒng)監(jiān)控到Goroutine運(yùn)行超過10ms時(shí),會(huì)認(rèn)為這是一個(gè)大協(xié)程,這個(gè)協(xié)程會(huì)引發(fā)其它協(xié)程產(chǎn)生饑餓問題,系統(tǒng)會(huì)將g結(jié)構(gòu)體里面的stackguard0字段的值置為0xfffffade,這個(gè)值有個(gè)解釋含義叫搶占標(biāo)志。
搶占
在調(diào)用runtime.morestack_noctxt方法時(shí)會(huì)判斷是否被搶占,如果被搶占,就回到schedule方法。
進(jìn)入go源碼,我們看下runtime.morestack_noctxt這個(gè)方法,在runtime/stubs.go這個(gè)文件中,發(fā)現(xiàn)只有聲明,沒有實(shí)現(xiàn),說明是用匯編實(shí)現(xiàn)的:
func morestack_noctxt()
每個(gè)平臺(tái)都實(shí)現(xiàn)了對(duì)應(yīng)的morestack_noctxt方法,說明這個(gè)方法的邏輯是平臺(tái)相關(guān)的,我們找一個(gè)runtime/asm_amd64.s文件下的morestack_noctxt方法來(lái)查看:
// morestack but not preserving ctxt. TEXT runtime·morestack_noctxt(SB),NOSPLIT,$0 MOVL $0, DX JMP runtime·morestack(SB)
不保留上下文,跳轉(zhuǎn)到了runtime.morestack方法,進(jìn)入這個(gè)方法,發(fā)現(xiàn)也只有聲明,沒有實(shí)現(xiàn),說明是用匯編實(shí)現(xiàn)的:
func morestack()
我們找一個(gè)runtime/asm_amd64.s文件下的morestack方法來(lái)查看:
TEXT runtime·morestack(SB),NOSPLIT,$0-0 // Cannot grow scheduler stack (m->g0). ...... CALL runtime·newstack(SB) ......
這里面大量的邏輯是處理?xiàng)I疃炔蛔愕膯栴},可以先不用管,在最后調(diào)用了一個(gè)runtime.newstack方法,可以進(jìn)入這個(gè)方法看下,在runtime/stack.go文件中:
//go:nowritebarrierrec
func newstack() {
stackguard0 := atomic.Loaduintptr(&gp.stackguard0)
preempt := stackguard0 == stackPreempt
if preempt {
// Act like goroutine called runtime.Gosched
gopreempt_m(gp) // never return
}
}
這個(gè)方法里面會(huì)處理很多新生成一個(gè)棧的邏輯,與我們的內(nèi)容沒有多大關(guān)系。注意這個(gè)地方,preempt就是搶占的意思,他會(huì)判斷一下g結(jié)構(gòu)體stackguard0這個(gè)值是否被標(biāo)記成了搶占這個(gè)值,就是0xfffffade,如果發(fā)現(xiàn)搶占的標(biāo)記是true,就會(huì)調(diào)用gopreempt_m方法,在這個(gè)方法里面會(huì)調(diào)用goschedImpl方法,goschedImpl最終會(huì)調(diào)用schedule方法,回到線程循環(huán)的起始點(diǎn)。
業(yè)務(wù)方法在執(zhí)行函數(shù)調(diào)用時(shí),會(huì)在morestack方法中判斷該協(xié)程是否標(biāo)記了搶占,如果被標(biāo)記了搶占,就會(huì)回到schedule方法,獲取新的協(xié)程進(jìn)行運(yùn)行,防止協(xié)程饑餓問題。

那上面這種方法是不是非常完美了呢,非也非也,繼續(xù)往下看
基于信號(hào)的搶占式調(diào)度
基于協(xié)作搶占的缺陷
上面基于協(xié)作的搶占式調(diào)度有什么樣的問題呢,看下面這段代碼:
func do1() {
i := 0
for true {
i++
}
}
func main() {
go do1()
}
這段代碼,既沒有系統(tǒng)調(diào)用,也不會(huì)主動(dòng)掛起(沒有涉及到鎖、channel、sleep等邏輯),更不會(huì)被標(biāo)記搶占,這個(gè)協(xié)程會(huì)永遠(yuǎn)占據(jù)這個(gè)線程。不信可以用匯編看下,里面沒有插入morestack_noctxt這個(gè)方法,因?yàn)闆]有函數(shù)調(diào)用。如果真的是這樣,多開幾個(gè)這樣的協(xié)程,那不是后面的其它協(xié)程都執(zhí)行不了,全部都饑餓。有大神想出了一招,那就是基于信號(hào)的搶占式調(diào)度,先看下什么是信號(hào),這里的信號(hào),指的是線程信號(hào)
線程信號(hào)
- 操作系統(tǒng)中,有很多基于信號(hào)的底層通信方式
- 比如Linux系統(tǒng)有SIGPIPE、SIGURG、SIGHUP信號(hào)
- 線程可以注冊(cè)對(duì)應(yīng)信號(hào)的處理函數(shù)
實(shí)現(xiàn)
- 注冊(cè)SIGURG信號(hào)的處理函數(shù)(這個(gè)信號(hào)不常用)
- GC工作時(shí),向目標(biāo)線程發(fā)送信號(hào)(GC時(shí),很多線程業(yè)務(wù)都停了,適合做搶占)
- 線程收到信號(hào),觸發(fā)調(diào)度
原理圖如下,注冊(cè)的這個(gè)信號(hào)處理函數(shù)就叫doSigPreempt,做信號(hào)搶占。當(dāng)垃圾回收器GC發(fā)送SIGURG搶占信號(hào)后,陷入到業(yè)務(wù)方法中的線程會(huì)立即跳轉(zhuǎn)到doSigPreempt方法,這個(gè)方法其實(shí)就是做重新調(diào)度循環(huán)。

看下這個(gè)doSigPreempt方法的源碼,在runtime/signal_unix.go這個(gè)文件中:
// doSigPreempt handles a preemption signal on gp.
func doSigPreempt(gp *g, ctxt *sigctxt) {
ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
}
這個(gè)方法最核心的就是會(huì)調(diào)用asyncPreempt這個(gè)方法,這個(gè)方法是由匯編實(shí)現(xiàn)的,在runtime/preempt.go這個(gè)文件中:
// asyncPreempt is implemented in assembly. func asyncPreempt()
asyncPreempt方法會(huì)調(diào)回來(lái),調(diào)到它下面這個(gè)asyncPreempt2方法,這個(gè)方法里面會(huì)通過mcall調(diào)用preemptPark方法。
func asyncPreempt2() {
......
mcall(preemptPark)
......
}
preemptPark這個(gè)方法,最終會(huì)調(diào)用schedule()這個(gè)方法,回到線程循環(huán)的起始點(diǎn)。
func preemptPark(gp *g) {
schedule()
}
總結(jié)
- 基于系統(tǒng)調(diào)用和主動(dòng)掛起,協(xié)程可能無(wú)法調(diào)度
- 基于協(xié)作的搶占式調(diào)度:業(yè)務(wù)主動(dòng)調(diào)用morestack()
- 基于信號(hào)的搶占式調(diào)度:強(qiáng)制線程調(diào)用doSigPreempt()
以上為個(gè)人經(jīng)驗(yàn),希望能給大家一個(gè)參考,也希望大家多多支持腳本之家。
相關(guān)文章
golang服務(wù)報(bào)錯(cuò):?write:?broken?pipe的解決方案
在開發(fā)在線客服系統(tǒng)的時(shí)候,看到日志里有一些錯(cuò)誤信息,下面這篇文章主要給大家介紹了關(guān)于golang服務(wù)報(bào)錯(cuò):?write:?broken?pipe的解決方案,需要的朋友可以參考下2022-09-09
Go語(yǔ)言中的定時(shí)器原理與實(shí)戰(zhàn)應(yīng)用
在Go語(yǔ)言中,Timer和Ticker是處理定時(shí)任務(wù)的重要工具,Timer用于一次性事件,而Ticker則用于周期性事件,本文詳細(xì)介紹了這兩種定時(shí)器的創(chuàng)建、使用和停止方法,并通過實(shí)際案例展示了它們?cè)诒O(jiān)控日志、檢查系統(tǒng)狀態(tài)等方面的應(yīng)用2024-10-10
Golang實(shí)現(xiàn)獲取系統(tǒng)環(huán)境變量的方法詳解
這篇文章主要為大家詳細(xì)介紹了Golang實(shí)現(xiàn)獲取系統(tǒng)環(huán)境變量的相關(guān)方法,文中的示例代碼講解詳細(xì),感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下2026-04-04
Go標(biāo)準(zhǔn)庫(kù)之Requests的介紹與基本使用
Python中的Requests庫(kù)非常強(qiáng)大,所以Go開發(fā)者模仿Python的Requests庫(kù),由此誕生了Grequests庫(kù),本文主要介紹了Requests的基本使用,有需要的可以參考下2024-04-04
Go單元測(cè)試對(duì)數(shù)據(jù)庫(kù)CRUD進(jìn)行Mock測(cè)試
這篇文章主要為大家介紹了Go單元測(cè)試對(duì)數(shù)據(jù)庫(kù)CRUD進(jìn)行Mock測(cè)試的示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-06-06
使用go語(yǔ)言實(shí)現(xiàn)查找兩個(gè)數(shù)組的異同操作
這篇文章主要介紹了使用go語(yǔ)言實(shí)現(xiàn)查找兩個(gè)數(shù)組的異同操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來(lái)看看吧2020-12-12
golang微服務(wù)框架基礎(chǔ)Gin基本路由使用詳解
這篇文章主要為大家介紹了golang微服務(wù)框架Gin基本路由的使用示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步2021-11-11

