詳解 Go 程序的啓動流程,你知道 g0,m0 是什麼嗎?

微信搜索【 腦子進煎魚了】關注這一隻爆肝煎魚。本文 GitHub github.com/eddycjy/blog 已收錄,有個人系列文章、資料和開源 Go 圖書。

你們好,我是煎魚。linux

自古應用程序均從 Hello World 開始,你我所寫的 Go 語言亦然:git

import "fmt"

func main() {
    fmt.Println("hello world.")
}

這段程序的輸出結果爲 hello world.,就是這麼的簡單又直接。但這時候又不由思考了起來,這個 hello world. 是怎麼輸出來,經歷了什麼過程。github

真是很是的好奇,今天咱們就一塊兒來探一探 Go 程序的啓動流程。
其中涉及到 Go Runtime 的調度器啓動,g0,m0 又是什麼?golang

車門焊死,正式開始吸魚之路。面試

Go 引導階段

查找入口

首先編譯上文提到的示例程序:算法

$ GOFLAGS="-ldflags=-compressdwarf=false" go build

在命令中指定了 GOFLAGS 參數,這是由於在 Go1.11 起,爲了減小二進制文件大小,調試信息會被壓縮。致使在 MacOS 上使用 gdb 時沒法理解壓縮的 DWARF 的含義是什麼(而我偏偏就是用的 MacOS)。shell

所以須要在本次調試中將其關閉,再使用 gdb 進行調試,以此達到觀察的目的:微信

$ gdb awesomeProject 
(gdb) info files
Symbols from "/Users/eddycjy/go-application/awesomeProject/awesomeProject".
Local exec file:
    `/Users/eddycjy/go-application/awesomeProject/awesomeProject', file type mach-o-x86-64.
    Entry point: 0x1063c80
    0x0000000001001000 - 0x00000000010a6aca is .text
    ...
(gdb) b *0x1063c80
Breakpoint 1 at 0x1063c80: file /usr/local/Cellar/go/1.15/libexec/src/runtime/rt0_darwin_amd64.s, line 8.

經過 Entry point 的調試,可看到真正的程序入口在 runtime 包中,不一樣的計算機架構指向不一樣。例如:數據結構

  • MacOS 在 src/runtime/rt0_darwin_amd64.s
  • Linux 在 src/runtime/rt0_linux_amd64.s

其最終指向了 rt0_darwin_amd64.s 文件,這個文件名稱很是的直觀:架構

Breakpoint 1 at 0x1063c80: file /usr/local/Cellar/go/1.15/libexec/src/runtime/rt0_darwin_amd64.s, line 8.

rt0 表明 runtime0 的縮寫,指代運行時的創世,超級奶爸:

  • darwin 表明目標操做系統(GOOS)。
  • amd64 表明目標操做系統架構(GOHOSTARCH)。

同時 Go 語言還支持更多的目標系統架構,例如:AMD6四、AMR、MIPS、WASM 等:

源碼目錄

如有興趣可到 src/runtime 目錄下進一步查看,這裏就不一一介紹了。

入口方法

在 rt0_linux_amd64.s 文件中,可發現 _rt0_amd64_darwin JMP 跳轉到了 _rt0_amd64 方法:

TEXT _rt0_amd64_darwin(SB),NOSPLIT,$-8
    JMP    _rt0_amd64(SB)
...

緊接着又跳轉到 runtime·rt0_go 方法:

TEXT _rt0_amd64(SB),NOSPLIT,$-8
    MOVQ    0(SP), DI    // argc
    LEAQ    8(SP), SI    // argv
    JMP    runtime·rt0_go(SB)

該方法將程序輸入的 argc 和 argv 從內存移動到寄存器中。

棧指針(SP)的前兩個值分別是 argc 和 argv,其對應參數的數量和具體各參數的值。

開啓主線

程序參數準備就緒後,正式初始化的方法落在 runtime·rt0_go 方法中:

TEXT runtime·rt0_go(SB),NOSPLIT,$0
    ...
    CALL    runtime·check(SB)
    MOVL    16(SP), AX        // copy argc
    MOVL    AX, 0(SP)
    MOVQ    24(SP), AX        // copy argv
    MOVQ    AX, 8(SP)
    CALL    runtime·args(SB)
    CALL    runtime·osinit(SB)
    CALL    runtime·schedinit(SB)

    // create a new goroutine to start program
    MOVQ    $runtime·mainPC(SB), AX        // entry
    PUSHQ    AX
    PUSHQ    $0            // arg size
    CALL    runtime·newproc(SB)
    POPQ    AX
    POPQ    AX

    // start this M
    CALL    runtime·mstart(SB)
    ...
  • runtime.check:運行時類型檢查,主要是校驗編譯器的翻譯工做是否正確,是否有 「坑」。基本代碼均爲檢查 int8unsafe.Sizeof 方法下是否等於 1 這類動做。
  • runtime.args:系統參數傳遞,主要是將系統參數轉換傳遞給程序使用。
  • runtime.osinit:系統基本參數設置,主要是獲取 CPU 核心數和內存物理頁大小。
  • runtime.schedinit:進行各類運行時組件的初始化,包含調度器、內存分配器、堆、棧、GC 等一大堆初始化工做。會進行 p 的初始化,並將 m0 和某一個 p 進行綁定。
  • runtime.main:主要工做是運行 main goroutine,雖然在runtime·rt0_go 中指向的是$runtime·mainPC,但實質指向的是 runtime.main
  • runtime.newproc:建立一個新的 goroutine,且綁定 runtime.main 方法(也就是應用程序中的入口 main 方法)。並將其放入 m0 綁定的p的本地隊列中去,以便後續調度。
  • runtime.mstart:啓動 m,調度器開始進行循環調度。

runtime·rt0_go 方法中,其主要是完成各種運行時的檢查,系統參數設置和獲取,並進行大量的 Go 基礎組件初始化。

初始化完畢後進行主協程(main goroutine)的運行,並放入等待隊列(GMP 模型),最後調度器開始進行循環調度。

小結

根據上述源碼剖析,能夠得出以下 Go 應用程序引導的流程圖:

Go 程序引導過程

在 Go 語言中,實際的運行入口並非用戶平常所寫的 main func,更不是 runtime.main 方法,而是從 rt0_*_amd64.s 開始,最終再一路 JMP 到 runtime·rt0_go 裏去,再在該方法裏完成一系列 Go 自身所須要完成的絕大部分初始化動做。

其中總體包括:

  • 運行時類型檢查、系統參數傳遞、CPU 核數獲取及設置、運行時組件的初始化(調度器、內存分配器、堆、棧、GC 等)。
  • 運行 main goroutine。
  • 運行相應的 GMP 等大量缺省行爲。
  • 涉及到調度器相關的大量知識。

後續將會繼續剖析將進一步剖析 runtime·rt0_go 裏的愛與恨,尤爲像是 runtime.mainruntime.schedinit 等調度方法,都有很是大的學習價值,有興趣的小夥伴能夠持續關注。

Go 調度器初始化

知道了 Go 程序是怎麼引導起來的以後,咱們須要瞭解 Go Runtime 中調度器是怎麼流轉的。

runtime.mstart

這裏主要關注 runtime.mstart 方法:

func mstart() {
    // 獲取 g0
    _g_ := getg()

    // 肯定棧邊界
    osStack := _g_.stack.lo == 0
    if osStack {
        size := _g_.stack.hi
        if size == 0 {
            size = 8192 * sys.StackGuardMultiplier
        }
        _g_.stack.hi = uintptr(noescape(unsafe.Pointer(&size)))
        _g_.stack.lo = _g_.stack.hi - size + 1024
    }
    _g_.stackguard0 = _g_.stack.lo + _StackGuard
    _g_.stackguard1 = _g_.stackguard0
  
  // 啓動 m,進行調度器循環調度
    mstart1()

    // 退出線程
    if mStackIsSystemAllocated() {
        osStack = true
    }
    mexit(osStack)
}
  • 調用 getg 方法獲取 GMP 模型中的 g,此處獲取的是 g0。
  • 經過檢查 g 的執行棧 _g_.stack 的邊界(堆棧的邊界正好是 lo, hi)來肯定是否爲系統棧。如果,則根據系統棧初始化 g 執行棧的邊界。
  • 調用 mstart1 方法啓動系統線程 m,進行調度器循環調度。
  • 調用 mexit 方法退出系統線程 m。

runtime.mstart1

這麼看來其實質邏輯在 mstart1 方法,咱們繼續往下剖析:

func mstart1() {
    // 獲取 g,並判斷是否爲 g0
    _g_ := getg()
    if _g_ != _g_.m.g0 {
        throw("bad runtime·mstart")
    }

    // 初始化 m 並記錄調用方 pc、sp
    save(getcallerpc(), getcallersp())
    asminit()
    minit()

    // 設置信號 handler
    if _g_.m == &m0 {
        mstartm0()
    }
    // 運行啓動函數
    if fn := _g_.m.mstartfn; fn != nil {
        fn()
    }

    if _g_.m != &m0 {
        acquirep(_g_.m.nextp.ptr())
        _g_.m.nextp = 0
    }
    schedule()
}
  • 調用 getg 方法獲取 g。而且經過前面綁定的 _g_.m.g0 判斷所獲取的 g 是否 g0。若不是,則直接拋出致命錯誤。由於調度器僅在 g0 上運行。
  • 調用 minit 方法初始化 m,並記錄調用方的 PC、SP,便於後續 schedule 階段時的複用。
  • 若肯定當前的 g 所綁定的 m 是 m0,則調用 mstartm0 方法,設置信號 handler。該動做必須在 minit 方法以後,這樣 minit 方法能夠提早準備好線程,以便可以處理信號。
  • 若當前 g 所綁定的 m 有啓動函數,則運行。不然跳過。
  • 若當前 g 所綁定的 m 不是 m0,則須要調用 acquirep 方法獲取並綁定 p,也就是 m 與 p 綁定。
  • 調用 schedule 方法進行正式調度。

忙活了一大圈,終於進入到開題的主菜了,原來潛伏的很深的 schedule 方法纔是真正作調度的方法,其餘都是前置處理和準備數據。

因爲篇幅問題,schedule 方法會放到下篇再繼續剖析,咱們先聚焦本篇的一些細節點。

問題深剖

不過到這裏篇幅也已經比較長了,積累了很多問題。咱們針對在 Runtime 中出鏡率最高的兩個元素進行剖析:

  1. m0 是什麼,做用是?
  2. g0 是什麼,做用是?

m0

m0 是 Go Runtime 所建立的第一個系統線程,一個 Go 進程只有一個 m0,也叫主線程。

從多個方面來看:

  • 數據結構:m0 和其餘建立的 m 沒有任何區別。
  • 建立過程:m0 是進程在啓動時應該彙編直接複製給 m0 的,其餘後續的 m 則都是 Go Runtime 內自行建立的。
  • 變量聲明:m0 和常規 m 同樣,m0 的定義就是 var m0 m,沒什麼特別之處。

g0

g 通常分爲三種,分別是:

  • 執行用戶任務的叫作 g。
  • 執行 runtime.main 的 main goroutine。
  • 執行調度任務的叫 g0。。

g0 比較特殊,每個 m 都只有一個 g0(僅此只有一個 g0),且每一個 m 都只會綁定一個 g0。在 g0 的賦值上也是經過彙編賦值的,其他後續所建立的都是常規的 g。

從多個方面來看:

  • 數據結構:g0 和其餘建立的 g 在數據結構上是同樣的,可是存在棧的差異。在 g0 上的棧分配的是系統棧,在 Linux 上棧大小默認固定 8MB,不能擴縮容。 而常規的 g 起始只有 2KB,可擴容。
  • 運行狀態:g0 和常規的 g 不同,沒有那麼多種運行狀態,也不會被調度程序搶佔,調度自己就是在 g0 上運行的。
  • 變量聲明:g0 和常規 g,g0 的定義就是 var g0 g,沒什麼特別之處。

小結

在本章節中咱們講解了 Go 調度器初始化的一個過程,分別涉及:

  • runtime.mstart。
  • runtime.mstart1。

基於此也瞭解到了在調度器初始化過程當中,須要準備什麼,初始化什麼。另外針對調度過程當中最常提到的 m0、g0 的概念咱們進行了梳理和說明。

總結

在今天這篇文章中,咱們詳細的介紹了 Go 語言的引導啓動過程當中的全部流程和初始化動做。

同時針對調度器的初始化進行了初步分析,詳細介紹了 m0、g0 的用途和區別。
在下一篇文章中咱們將進一步對真正調度的 schedule 方法進行詳解,這塊也是個硬骨頭了。

如有任何疑問歡迎評論區反饋和交流,最好的關係是互相成就,各位的點贊就是煎魚創做的最大動力,感謝支持。

文章持續更新,能夠微信搜【腦子進煎魚了】閱讀,回覆【 000】有我準備的一線大廠面試算法題解和資料;本文 GitHub github.com/eddycjy/blog 已收錄,歡迎 Star 催更。
相關文章
相關標籤/搜索