Go runtime 排程器精講(六):非 main goroutine 執行

胡云Troy發表於2024-09-14

原創文章,歡迎轉載,轉載請註明出處,謝謝。


0. 前言

Go runtime 排程器精講(三):main goroutine 建立 介紹了 main goroutine 的建立,文中我們說 main goroutine 和非 main goroutine 有區別。當時賣了個關子並未往下講,這一講我們會繼續介紹非 main goroutine (也就是 go 關鍵字建立的 goroutine,後文統稱為 gp) 的執行,並且把這個關子解開,說一說它們的區別在哪兒。

1. gp 的建立

首先看一個示例:

func g2() {
    time.Sleep(10 * time.Second)
	println("hello world")
}

func main() {
	go g2()

	time.Sleep(1 * time.Minute)
	println("main exit")
}

main 函式建立兩個 goroutine,一個 main goroutine,一個普通 goroutine。從 Go runtime 排程器精講(四):執行 main goroutine 可知 main goroutine 執行完之後就呼叫 exit(0) 退出了。為了能進入 gp,我們這裡在 main goroutine 中加了 1 分鐘的等待時間。

Go runtime 的啟動在前幾講都有介紹,這裡直接進入 main 函式,檢視 gp 是如何建立的:

(dlv) c
> main.main() ./goexit.go:12 (hits goroutine(1):1 total:1) (PC: 0x46238a)
     7: func g2() {
     8:         time.Sleep(10 * time.Second)
     9:         println("hello world")
    10: }
    11:
=>  12: func main() {
    13:         go g2()
    14:
    15:         time.Sleep(30 * time.Minute)
    16:         println("main exit")
    17: }

直接看 main 函式,我們看不出 go 關鍵字做了什麼,檢視 CPU 的彙編指令:

(dlv) si
> main.main() ./goexit.go:13 (PC: 0x462395)
        goexit.go:12    0x462384        7645                    jbe 0x4623cb
        goexit.go:12    0x462386        55                      push rbp
        goexit.go:12    0x462387        4889e5                  mov rbp, rsp
        goexit.go:12    0x46238a*       4883ec10                sub rsp, 0x10
        goexit.go:13    0x46238e        488d050b7a0100          lea rax, ptr [rip+0x17a0b]
=>      goexit.go:13    0x462395        e8c6b1fdff              call $runtime.newproc
        goexit.go:15    0x46239a        48b800505c18a3010000    mov rax, 0x1a3185c5000
        goexit.go:15    0x4623a4        e8b79fffff              call $time.Sleep

可以看到,go 關鍵字被編譯轉換後實際呼叫的是 $runtime.newproc 函式,這個函式在 Go runtime 排程器精講(四):執行 main goroutine 已經非常詳細的介紹過了,這裡就不贅述了。

有必要在說明的是,main goroutine 和普通 goroutine 執行的順序。當呼叫 runtime.newproc 後,gp 被新增到 P 的可執行佇列(如果佇列滿,被新增到全域性佇列),接著執行緒會排程執行該 gp。不過對於 newproc 來說,gp 放入佇列後,newproc 就退出了。接著執行後續的 main goroutine 程式碼。

如果此時 gp 未執行或者未結束,並且 main goroutine 未等待/阻塞的話,main goroutine 將直接退出。

2. gp 的退出

前面說 gp 和 main goroutine 的區別主要體現在 goroutine 的退出這裡。main goroutine 的退出比較殘暴,直接呼叫 exit(0) 退出程序。那麼,gp 是怎麼退出的呢?

我們在 g2 結束點處打斷點,看看 g2 是怎麼退出的:

(dlv) b ./goexit.go:10
Breakpoint 1 set at 0x46235b for main.g2() ./goexit.go:10
(dlv) c
hello world
> main.g2() ./goexit.go:10 (hits goroutine(5):1 total:1) (PC: 0x46235b)
     7: func g2() {
     8:         time.Sleep(10 * time.Second)
     9:         println("hello world")
=>  10: }
    11:
    12: func main() {
    13:         go g2()
    14:
    15:         time.Sleep(30 * time.Minute)
(dlv) si
> main.g2() ./goexit.go:10 (PC: 0x46235f)
        goexit.go:9     0x462345        488d05b81b0100  lea rax, ptr [rip+0x11bb8]
        goexit.go:9     0x46234c        bb0c000000      mov ebx, 0xc
        goexit.go:9     0x462351        e88a30fdff      call $runtime.printstring
        goexit.go:9     0x462356        e86528fdff      call $runtime.printunlock
        goexit.go:10    0x46235b*       4883c410        add rsp, 0x10
=>      goexit.go:10    0x46235f        5d              pop rbp
        goexit.go:10    0x462360        c3              ret
        goexit.go:7     0x462361        e89ab1ffff      call $runtime.morestack_noctxt
        goexit.go:7     0x462366        ebb8            jmp $main.g2

CPU 執行指令到 pop rbp,接著執行 ret:

        goexit.go:10    0x46235f        5d              pop rbp
=>      goexit.go:10    0x462360        c3              ret
        goexit.go:7     0x462361        e89ab1ffff      call $runtime.morestack_noctxt
        goexit.go:7     0x462366        ebb8            jmp $main.g2
(dlv) si
> runtime.goexit() /usr/local/go/src/runtime/asm_amd64.s:1651 (PC: 0x45d7a1)
Warning: debugging optimized function
TEXT runtime.goexit(SB) /usr/local/go/src/runtime/asm_amd64.s
        asm_amd64.s:1650        0x45d7a0        90              nop
=>      asm_amd64.s:1651        0x45d7a1        e8ba250000      call $runtime.goexit1
        asm_amd64.s:1653        0x45d7a6        90              nop

我們看到了什麼,執行 ret 直接跳轉到了 call $runtime.goexit1。還記得在 Go runtime 排程器精講(三):main goroutine 建立 中說每個 goroutine 棧都會在“棧頂”放 funcPC(goexit) + 1 的地址。這裡實際是做了一個偷樑換柱,gp 的棧在退出執行 ret 時都會跳轉到 call $runtime.goexit1 繼續執行。

進入 runtime.goexit1

// Finishes execution of the current goroutine.
func goexit1() {
	...
	mcall(goexit0)                          // mcall 會切換當前棧到 g0 棧,接著在 g0 棧執行 goexit0
}

實際執行的是 goexit0

// goexit continuation on g0.
func goexit0(gp *g) {
    mp := getg().m                          // 這裡是 g0 棧,mp = m0
	pp := mp.p.ptr()                        // m0 繫結的 P

    casgstatus(gp, _Grunning, _Gdead)       // 將 gp 的狀態更新為 _Gdead
    gp.m = nil                              // 將 gp 繫結的執行緒更新為 nil,和執行緒解綁
    ...

    dropg()                                 // 將當前執行緒和 gp 解綁
    ...
    gfput(pp, gp)                           // 退出的 gp 還是可以重用的,gfput 將 gp 放到本地或者全域性空閒佇列中

    ...
    schedule()                              // 執行緒執行完一個 gp 還沒有退出,繼續進入 schedule 找 goroutine 執行
}

gp 退出了,執行緒並沒有退出,執行緒將 gp 安頓好之後,繼續開始新一輪排程,真是勞模啊。

3. 小結

本講介紹了用 go 關鍵字建立的 goroutine 是如何執行的,下一講我們放鬆放鬆,看幾個案例分析排程器的行為。


相關文章