詳解 Go 程序的啟動流程,你知道 g0,m0 是什么嗎?
大家好,我是煎魚。
自古應(yīng)用程序均從 Hello World 開始,你我所寫的 Go 語言亦然:
- import "fmt"
- func main() {
- fmt.Println("hello world.")
- }
這段程序的輸出結(jié)果為 hello world.,就是這么的簡單又直接。但這時候又不禁思考了起來,這個 hello world. 是怎么輸出來,經(jīng)歷了什么過程。
真是非常的好奇,今天我們就一起來探一探 Go 程序的啟動流程。其中涉及到 Go Runtime 的調(diào)度器啟動,g0,m0 又是什么?
車門焊死,正式開始吸魚之路。
Go 引導(dǎo)階段
查找入口
首先編譯上文提到的示例程序:
- $ GOFLAGS="-ldflags=-compressdwarf=false" go build
在命令中指定了 GOFLAGS 參數(shù),這是因為在 Go1.11 起,為了減少二進(jìn)制文件大小,調(diào)試信息會被壓縮。導(dǎo)致在 MacOS 上使用 gdb 時無法理解壓縮的 DWARF 的含義是什么(而我恰恰就是用的 MacOS)。
因此需要在本次調(diào)試中將其關(guān)閉,再使用 gdb 進(jìn)行調(diào)試,以此達(dá)到觀察的目的:
- $ 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 的調(diào)試,可看到真正的程序入口在 runtime 包中,不同的計算機(jī)架構(gòu)指向不同。例如:
- 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 的縮寫,指代運行時的創(chuàng)世,超級奶爸:
- darwin 代表目標(biāo)操作系統(tǒng)(GOOS)。
- amd64 代表目標(biāo)操作系統(tǒng)架構(gòu)(GOHOSTARCH)。
同時 Go 語言還支持更多的目標(biāo)系統(tǒng)架構(gòu),例如:AMD64、AMR、MIPS、WASM 等:
源碼目錄
若有興趣可到 src/runtime 目錄下進(jìn)一步查看,這里就不一一介紹了。
入口方法
在 rt0_linux_amd64.s 文件中,可發(fā)現(xiàn) _rt0_amd64_darwin JMP 跳轉(zhuǎn)到了 _rt0_amd64 方法:
- TEXT _rt0_amd64_darwin(SB),NOSPLIT,$-8
- JMP _rt0_amd64(SB)
- ...
緊接著又跳轉(zhuǎn)到 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 從內(nèi)存移動到寄存器中。
棧指針(SP)的前兩個值分別是 argc 和 argv,其對應(yīng)參數(shù)的數(shù)量和具體各參數(shù)的值。
開啟主線
程序參數(shù)準(zhǔn)備就緒后,正式初始化的方法落在 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:運行時類型檢查,主要是校驗編譯器的翻譯工作是否正確,是否有 “坑”?;敬a均為檢查 int8 在 unsafe.Sizeof 方法下是否等于 1 這類動作。
- runtime.args:系統(tǒng)參數(shù)傳遞,主要是將系統(tǒng)參數(shù)轉(zhuǎn)換傳遞給程序使用。
- runtime.osinit:系統(tǒng)基本參數(shù)設(shè)置,主要是獲取 CPU 核心數(shù)和內(nèi)存物理頁大小。
- runtime.schedinit:進(jìn)行各種運行時組件的初始化,包含調(diào)度器、內(nèi)存分配器、堆、棧、GC 等一大堆初始化工作。會進(jìn)行 p 的初始化,并將 m0 和某一個 p 進(jìn)行綁定。
- runtime.main:主要工作是運行 main goroutine,雖然在runtime·rt0_go 中指向的是$runtime·mainPC,但實質(zhì)指向的是 runtime.main。
- runtime.newproc:創(chuàng)建一個新的 goroutine,且綁定 runtime.main 方法(也就是應(yīng)用程序中的入口 main 方法)。并將其放入 m0 綁定的p的本地隊列中去,以便后續(xù)調(diào)度。
- runtime.mstart:啟動 m,調(diào)度器開始進(jìn)行循環(huán)調(diào)度。
- 在 runtime·rt0_go 方法中,其主要是完成各類運行時的檢查,系統(tǒng)參數(shù)設(shè)置和獲取,并進(jìn)行大量的 Go 基礎(chǔ)組件初始化。
初始化完畢后進(jìn)行主協(xié)程(main goroutine)的運行,并放入等待隊列(GMP 模型),最后調(diào)度器開始進(jìn)行循環(huán)調(diào)度。
小結(jié)
根據(jù)上述源碼剖析,可以得出如下 Go 應(yīng)用程序引導(dǎo)的流程圖:
Go 程序引導(dǎo)過程
在 Go 語言中,實際的運行入口并不是用戶日常所寫的 main func,更不是 runtime.main 方法,而是從 rt0_*_amd64.s 開始,最終再一路 JMP 到 runtime·rt0_go 里去,再在該方法里完成一系列 Go 自身所需要完成的絕大部分初始化動作。
其中整體包括:
- 運行時類型檢查、系統(tǒng)參數(shù)傳遞、CPU 核數(shù)獲取及設(shè)置、運行時組件的初始化(調(diào)度器、內(nèi)存分配器、堆、棧、GC 等)。
- 運行 main goroutine。
- 運行相應(yīng)的 GMP 等大量缺省行為。
- 涉及到調(diào)度器相關(guān)的大量知識。
后續(xù)將會繼續(xù)剖析將進(jìn)一步剖析 runtime·rt0_go 里的愛與恨,尤其像是 runtime.main、runtime.schedinit 等調(diào)度方法,都有非常大的學(xué)習(xí)價值,有興趣的小伙伴可以持續(xù)關(guān)注。
Go 調(diào)度器初始化
知道了 Go 程序是怎么引導(dǎo)起來的之后,我們需要了解 Go Runtime 中調(diào)度器是怎么流轉(zhuǎn)的。
runtime.mstart
這里主要關(guān)注 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,進(jìn)行調(diào)度器循環(huán)調(diào)度
- mstart1()
- // 退出線程
- if mStackIsSystemAllocated() {
- osStack = true
- }
- mexit(osStack)
- }
- 調(diào)用 getg 方法獲取 GMP 模型中的 g,此處獲取的是 g0。
- 通過檢查 g 的執(zhí)行棧 _g_.stack 的邊界(堆棧的邊界正好是 lo, hi)來確定是否為系統(tǒng)棧。若是,則根據(jù)系統(tǒng)棧初始化 g 執(zhí)行棧的邊界。
- 調(diào)用 mstart1 方法啟動系統(tǒng)線程 m,進(jìn)行調(diào)度器循環(huán)調(diào)度。
- 調(diào)用 mexit 方法退出系統(tǒng)線程 m。
runtime.mstart1
這么看來其實質(zhì)邏輯在 mstart1 方法,我們繼續(xù)往下剖析:
- func mstart1() {
- // 獲取 g,并判斷是否為 g0
- _g_ := getg()
- if _g_ != _g_.m.g0 {
- throw("bad runtime·mstart")
- }
- // 初始化 m 并記錄調(diào)用方 pc、sp
- save(getcallerpc(), getcallersp())
- asminit()
- minit()
- // 設(shè)置信號 handler
- if _g_.m == &m0 {
- mstartm0()
- }
- // 運行啟動函數(shù)
- if fn := _g_.m.mstartfn; fn != nil {
- fn()
- }
- if _g_.m != &m0 {
- acquirep(_g_.m.nextp.ptr())
- _g_.m.nextp = 0
- }
- schedule()
- }
- 調(diào)用 getg 方法獲取 g。并且通過前面綁定的 _g_.m.g0 判斷所獲取的 g 是否 g0。若不是,則直接拋出致命錯誤。因為調(diào)度器僅在 g0 上運行。
- 調(diào)用 minit 方法初始化 m,并記錄調(diào)用方的 PC、SP,便于后續(xù) schedule 階段時的復(fù)用。
- 若確定當(dāng)前的 g 所綁定的 m 是 m0,則調(diào)用 mstartm0 方法,設(shè)置信號 handler。該動作必須在 minit 方法之后,這樣 minit 方法可以提前準(zhǔn)備好線程,以便能夠處理信號。
- 若當(dāng)前 g 所綁定的 m 有啟動函數(shù),則運行。否則跳過。
- 若當(dāng)前 g 所綁定的 m 不是 m0,則需要調(diào)用 acquirep 方法獲取并綁定 p,也就是 m 與 p 綁定。
- 調(diào)用 schedule 方法進(jìn)行正式調(diào)度。
忙活了一大圈,終于進(jìn)入到開題的主菜了,原來潛伏的很深的 schedule 方法才是真正做調(diào)度的方法,其他都是前置處理和準(zhǔn)備數(shù)據(jù)。
由于篇幅問題,schedule 方法會放到下篇再繼續(xù)剖析,我們先聚焦本篇的一些細(xì)節(jié)點。
問題深剖
不過到這里篇幅也已經(jīng)比較長了,積累了不少問題。我們針對在 Runtime 中出鏡率最高的兩個元素進(jìn)行剖析:
- m0 是什么,作用是?
- g0 是什么,作用是?
m0
m0 是 Go Runtime 所創(chuàng)建的第一個系統(tǒng)線程,一個 Go 進(jìn)程只有一個 m0,也叫主線程。
從多個方面來看:
- 數(shù)據(jù)結(jié)構(gòu):m0 和其他創(chuàng)建的 m 沒有任何區(qū)別。
- 創(chuàng)建過程:m0 是進(jìn)程在啟動時應(yīng)該匯編直接復(fù)制給 m0 的,其他后續(xù)的 m 則都是 Go Runtime 內(nèi)自行創(chuàng)建的。
- 變量聲明:m0 和常規(guī) m 一樣,m0 的定義就是 var m0 m,沒什么特別之處。
g0
- g 一般分為三種,分別是:
- 執(zhí)行用戶任務(wù)的叫做 g。
- 執(zhí)行 runtime.main 的 main goroutine。
執(zhí)行調(diào)度任務(wù)的叫 g0。。
g0 比較特殊,每一個 m 都只有一個 g0(僅此只有一個 g0),且每個 m 都只會綁定一個 g0。在 g0 的賦值上也是通過匯編賦值的,其余后續(xù)所創(chuàng)建的都是常規(guī)的 g。
從多個方面來看:
數(shù)據(jù)結(jié)構(gòu):g0 和其他創(chuàng)建的 g 在數(shù)據(jù)結(jié)構(gòu)上是一樣的,但是存在棧的差別。在 g0 上的棧分配的是系統(tǒng)棧,在 Linux 上棧大小默認(rèn)固定 8MB,不能擴(kuò)縮容。而常規(guī)的 g 起始只有 2KB,可擴(kuò)容。
運行狀態(tài):g0 和常規(guī)的 g 不一樣,沒有那么多種運行狀態(tài),也不會被調(diào)度程序搶占,調(diào)度本身就是在 g0 上運行的。
變量聲明:g0 和常規(guī) g,g0 的定義就是 var g0 g,沒什么特別之處。
小結(jié)
在本章節(jié)中我們講解了 Go 調(diào)度器初始化的一個過程,分別涉及:
- runtime.mstart。
- runtime.mstart1。
基于此也了解到了在調(diào)度器初始化過程中,需要準(zhǔn)備什么,初始化什么。另外針對調(diào)度過程中最常提到的 m0、g0 的概念我們進(jìn)行了梳理和說明。
總結(jié)
在今天這篇文章中,我們詳細(xì)的介紹了 Go 語言的引導(dǎo)啟動過程中的所有流程和初始化動作。
同時針對調(diào)度器的初始化進(jìn)行了初步分析,詳細(xì)介紹了 m0、g0 的用途和區(qū)別。在下一篇文章中我們將進(jìn)一步對真正調(diào)度的 schedule 方法進(jìn)行詳解,這塊也是個硬骨頭了。