最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

Go協(xié)程的底層原理及分析

 更新時(shí)間:2026年05月02日 14:19:12   作者:RdByte  
文章解釋了操作系統(tǒng)中的進(jìn)程、線程和協(xié)程的概念及其區(qū)別,并詳細(xì)描述了Go語(yǔ)言中協(xié)程的工作原理和調(diào)度機(jī)制,包括G-M-P模型、搶占式調(diào)度、基于信號(hào)的搶占式調(diào)度等

為什么要有協(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的邏輯,正常情況下nextrunnext,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.gonewproc方法中,這個(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)文章

最新評(píng)論

环江| 陇南市| 德庆县| 平邑县| 新闻| 黄山市| 黄山市| 什邡市| 沙洋县| 铜山县| 临洮县| 北川| 探索| 玉门市| 龙门县| 宜丰县| 英吉沙县| 岗巴县| 山阴县| 方城县| 利川市| 措勤县| 临桂县| 称多县| 吴堡县| 剑阁县| 喀什市| 体育| 富蕴县| 嘉峪关市| 洛川县| 驻马店市| 墨脱县| 莱芜市| 昂仁县| 宣武区| 大竹县| 宁阳县| 温泉县| 景德镇市| 镇坪县|