大家好,我是煎魚,
自古應用程式均從 Hello World 開始,你我所寫的 Go 語言亦然:
import "fmt"
func main() {
fmt.Println("hello world.")
}
這段程式的輸出結果為 hello world.,就是這么的簡單又直接,但這時候又不禁思考了起來,這個 hello world. 是怎么輸出來,經歷了什么程序,
真是非常的好奇,今天我們就一起來探一探 Go 程式的啟動流程,其中涉及到 Go Runtime 的調度器啟動,g0,m0 又是什么?
車門焊死,正式開始吸魚之路,
Go 引導階段
查找入口
首先編譯上文提到的示例程式:
$ GOFLAGS="-ldflags=-compressdwarf=false" go build
在命令中指定了 GOFLAGS 引數,這是因為在 Go1.11 起,為了減少二進制檔案大小,除錯資訊會被壓縮,導致在 MacOS 上使用 gdb 時無法理解壓縮的 DWARF 的含義是什么(而我恰恰就是用的 MacOS),
因此需要在本次除錯中將其關閉,再使用 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 語言還支持更多的目標系統架構,例如:AMD64、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:運行時型別檢查,主要是校驗編譯器的翻譯作業是否正確,是否有 “坑”,基本代碼均為檢查
int8在unsafe.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 語言中,實際的運行入口并不是用戶日常所寫的 main func,更不是 runtime.main 方法,而是從 rt0_*_amd64.s 開始,最終再一路 JMP 到 runtime·rt0_go 里去,再在該方法里完成一系列 Go 自身所需要完成的絕大部分初始化動作,
其中整體包括:
運行時型別檢查、系統引數傳遞、CPU 核數獲取及設定、運行時組件的初始化(調度器、記憶體分配器、堆、堆疊、GC 等),
運行 main goroutine,
運行相應的 GMP 等大量預設行為,
涉及到調度器相關的大量知識,
后續將會繼續剖析將進一步剖析 runtime·rt0_go 里的愛與恨,尤其像是 runtime.main、runtime.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 中出鏡率最高的兩個元素進行剖析:
m0是什么,作用是?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 方法進行詳解,這塊也是個硬骨頭了,
關注煎魚,吸取他的知識 ????

你好,我是煎魚,高一折騰過前端,參加過國賽拿了獎,大學搞過 PHP,現在整 Go,在公司負責微服務架構等相關作業推進和研發,
從大學開始靠自己賺生活費和學費,到出版 Go 暢銷書《Go 語言編程之旅》,再到獲得 GOP(Go 領域最有觀點專家)榮譽,點擊藍字查看我的出書之路,
日常分享高質量文章,輸出 Go 面試、作業經驗、架構設計,歡迎加我微信:cJY0728,替你連接數千位 Go 開發愛好者,記得點贊,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/276963.html
標籤:AI
