main的啟動過程

Hello_404發表於2020-12-20

main的啟動過程

參考連結

重點應解答以下問題

  1. go程式的大致啟動過程?
  2. 一個go程式預設到底是幾個goroutines?分別承擔什麼角色?
  3. go程式入口是什麼?中間大致需要有哪些準備?
  4. 涉及到的重點檔案或重點function?分別實現了什麼功能?
  5. 進階:gorutine 的"一生"

1. go 的出生

  • 環境:
    • 作業系統:CentOS Linux release 7.8.2003 (Core)
    • go版本:go version go1.13.11 linux/amd64
    • gdb版本:GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-119.el7

1.1 示例

  • 編寫 main.go

    package main
    import (
    	"fmt"
    )
    func main() {
    	fmt.Println("hello,world")
    }
    
  • 編譯

    go build -gcflags "-N -l" -o hello src/main.go
    

    -gcflags"-N -l" 是為了關閉編譯器優化和函式內聯,防止後面在設定斷點的時候找不到相對應的程式碼位置。得到了可執行檔案 main。

1.2 gdb 除錯

1.2.1 info files

  • 執行 info files

    在這裡插入圖片描述

    • Entry point: 0x454d30

      檔案執行的記憶體入口地址,由其值可看到入口在 .text (401000 - 48ce19) 段中。

    • .text
      程式碼段(codesegment/textsegment)通常是指用來存放程式執行程式碼的一塊記憶體區域。這部分割槽域的大小在程式執行前就已經確定,並且記憶體區域通常屬於只讀,某些架構也允許程式碼段為可寫,即允許修改程式。在程式碼段中,也有可能包含一些只讀的常數變數,例如字串常量等。

    • .rodata
      存放字串和#define定義的常量

    • .data
      資料段(datasegment)通常是指用來存放程式中已初始化的全域性變數的一塊記憶體區域。資料段屬於靜態記憶體分配。

    • .bss
      BSS段(bsssegment)通常是指用來存放程式中未初始化的全域性變數的一塊記憶體區域。BSS是英文BlockStarted by Symbol的簡稱。BSS段屬於靜態記憶體分配。

1.2.2 新增斷點

  • 設定斷點

    b _rt0_amd64_linux
    b _rt0_amd64
    b runtime.check
    b runtime.args
    b runtime.osinit
    b runtime.schedinit
    b runtime.newproc
    b runtime.mstart
    b main.main
    b runtime.exit
    
  • 列印斷點設定資訊如下

    在這裡插入圖片描述

1.3 列印斷點

Breakpoint 1:_rt0_amd64_linux

  • 檢視 Breakpoint 1 資訊

    [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-ahr3x9pw-1608406838359)(main%E5%90%AF%E5%8A%A8%E8%BF%87%E7%A8%8B.assets/image-20201118005003864.png)]

    由 Address 一列可以看到, Breakpoint 1 就是我們的程式入 口地址,其檔案為 src/runtime/rt0_linux_amd64.s。由此可見,go程式啟動不是直接進入main.main函式,而是需要進行命令列處理,執行時初始化等工作,這些工作基本由go提供的 runtime 執行,之後才會進入使用者邏輯。

    go的runtime 包下面有各種不同名稱的程式入口檔案,支援各種作業系統和架構,筆者機器為x86_64,因此入口檔案是rt0_linux_amd64.s。

  • 執行 Breakpoint 1

    在這裡插入圖片描述

    JMP _rt0_amd64(SB) 進入_rt0_amd64, 即Breakpoint 2。

Breakpoint 2:_rt0_amd64

在這裡插入圖片描述

_rt0_amd64在檔案asm_amd64.s內,這裡 LEAQ 是計算記憶體地址,然後把記憶體地址本身放進暫存器裡,也就是把 argv 的地址放到了SI 暫存器中。我們可以看一下程式碼檔案 asm_amd64.s

在這裡插入圖片描述

看註釋可知,_rt0_amd64 是大多數amd64系統的常用啟動程式碼。 這是核心中普通 -buildmode = exe 程式的程式入口點。 堆疊儲存引數數量argv和C風格的argv。然後呼叫 runtime.rt0_go

runtime.rt0_go做了什麼呢?繼續往下看該檔案,runtime.rt0_go也在asm_amd64.s檔案中,它其實已經大概展示了一個go程式的一生。

  1. 依次呼叫了 Breakpoint 3-6 的幾個函式來完成初始化和執行時啟動等一系列工作。
  2. 呼叫 Breakpoint 7的runtime.newproc新建gorutine,並繫結我們所編寫的main
  3. 呼叫 Breakpoint 8 的runtime.mstart來獲取 m ,執行我們的main函式,即Breakpoint9的 main_main。
  4. 最終在main_main執行完後返回runtime.main通過exit(0)呼叫runtime.exit正常結束程式,runtime.abort(SB)是程式異常時呼叫退出。(mstart should never return)

在這裡插入圖片描述

Breakpoint 3:runtime.check

runtime.rt0_go中,首先是呼叫runtime·check(SB),該函式實際是go程式碼了,主要是 CPU 相關的特性標誌位檢查的程式碼,以及對一些型別大小進行檢查等。

在這裡插入圖片描述

Breakpoint 4:runtime.args

然後是runtime·args(SB),作用就是將引數(argc 和 argv )儲存到靜態變數中。其實執行到runtime.sysargs就知道 c 和 v 其實就是argc和argv 了。

在這裡插入圖片描述

Breakpoint 5:runtime·osinit

接下來是runtime·osinit(SB),比較容易看出來,這個函式初始化了 CPU 邏輯核數和記憶體頁大小。

在這裡插入圖片描述

Breakpoint 6:runtime.schedinit

接下來是runtime·schedinit(SB),顧名思義,它幹了一大堆初始化排程器的事,包括:初始化命令列引數、環境變數、gc、棧空間、記憶體管理、P 例項、HASH演算法等,幾乎所有執行環境的初始化都在這裡執行了。

在這裡插入圖片描述

這裡我們具體看一下schedinit的程式碼:

// The bootstrap sequence is:
//
//      call osinit
//      call schedinit
//      make & queue new G
//      call runtime·mstart
//
// The new G calls runtime·main.
func schedinit() {
        // raceinit must be the first call to race detector.
        // In particular, it must be done before mallocinit below calls racemapshadow.
        _g_ := getg()
        if raceenabled {
                _g_.racectx, raceprocctx0 = raceinit() // 初始化競態檢測器
        }
	// 最大系統執行緒數量限制,這也說明了go是多執行緒模型。具體檢視 runtime/debug.SetMaxThreads
        sched.maxmcount = 10000  //sched結構體位於rutime2.go中
      
        // 初始棧跟蹤
        tracebackinit()
        // 模組資料校驗
        moduledataverify() 
        // 初始化棧空間
        stackinit()
        // 初始化記憶體分配器
        mallocinit()
        // 初始化 m
        mcommoninit(_g_.m)
        // 初始化cpu:cpu個數;cpu特性
       cpuinit()
       // 初始化AES hash演算法
       alginit() 
    
        // 模組初始化
        //  src\runtime\symtab.go
        //  當一個module被動態連結器首次載入時,.init_array被呼叫。
        //  在一個Go程式宣告週期內,有兩種場景會發生module的載入。
        //  1.以-buildmode=shared 模式編譯,程式初始化會載入。
        //  2.-buildmode=plugin模式編譯,程式執行時載入。
        //  約束:1.modulesinit一次只能有一個goroutine呼叫。
       modulesinit()
    
        // 使用 map 建立型別符號表
        typelinksinit()
        // itab初始化
        itabsinit()

        msigsave(_g_.m) 
        initSigmask = _g_.m.sigmask
      
        goargs()  // 處理命令列引數
        goenvs() // 獲取環境變數
        parsedebugvars()  // 處理 GODEBUG?GOTRACEBACK 除錯相關的環境變數設定
        gcinit()  //垃圾回收初始化

        sched.lastpoll = uint64(nanotime())
        //設定最大p數量上限值, 如果設定了環境變數,"GOMAXPROCS",則設定為環境變數設定值。
        procs := ncpu
        if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
                procs = n
        }
        if procresize(procs) != nil { // 調整p的數量
                throw("unknown runnable goroutine during bootstrap")
        }

       ...
}

Breakpoint 7:runtime.newproc

實際上,我們使用關鍵字開啟執行緒的時候入口就是runtime.newproc,即go func(){}實際上newproc(func(){})的呼叫。fn就是我們所編寫的使用者程式碼

在這裡插入圖片描述

runtime·newproc(SB)則使用系統棧(即systemstack)新建一個 goroutine。systemstack會切換到系統棧,然後呼叫傳入函式,具體如下:

  1. g0是執行緒繫結的。
  2. 如果是g0,或者gsignal goroutine,則直接呼叫呼叫傳入的函式。
  3. 否則,先切換到g0棧,再執行傳入函式。
  4. 執行完畢,切換回撥用者棧。

我們一步步執行看一下newproc1具體工作(這裡省去一些if判斷):

// Create a new g running fn with narg bytes of arguments starting
// at argp. callerpc is the address of the go statement that created
// this. The new g is put on the queue of g's waiting to run.
func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) {
	_g_ := getg()

	_p_ := _g_.m.p.ptr()
	// 從本地gFree獲取一個g物件,若gFree為空,全域性allgs非空,則從allgs獲取32個
	// 到gFree並返回一個;若全域性為空,則返回nil(go啟動過程返回nil)
	newg := gfget(_p_)
	if newg == nil {
		newg = malg(_StackMin) // 新建 gorutine
		casgstatus(newg, _Gidle, _Gdead) // newg狀態: _Gidle -> _Gdead
		allgadd(newg)  // 新增到全域性 allgs []*g
	}

	totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize 
	totalSize += -totalSize & (sys.SpAlign - 1) 
	sp := newg.stack.hi - totalSize // 棧頂
	spArg := sp

	memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
	newg.sched.sp = sp
	newg.stktopsp = sp
	newg.sched.pc = funcPC(goexit) + sys.PCQuantum 
	newg.sched.g = guintptr(unsafe.Pointer(newg))
	gostartcallfn(&newg.sched, fn) // 準備goroutine執行環節,將gobuf.pc設定為fn。
	newg.gopc = callerpc //  儲存呼叫者go語句地址
	newg.ancestors = saveAncestors(callergp) //  儲存呼叫者的g的地址
	newg.startpc = fn.fn
	
	newg.gcscanvalid = false
	casgstatus(newg, _Gdead, _Grunnable) // newg狀態: _Gdead -> _Grunnable

	runqput(_p_, newg, true) // 將新g放入本地待執行佇列。p.runq

	// 有空閒p && 沒有m處於自旋狀態 && main已啟動, 則喚醒(mainStarted在runtime.main執行後置為true)
	if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted {
		wakep()
		// wakep 邏輯:
		// 若無空閒p,即sched.pidle為空,則什麼也不做。
		// 若有空閒p:
		//     無空閒m,則新建一個m,將m與p關聯,新建一個系統執行緒,執行goroutine函式。
		//     有空閒m,則喚醒該m執行緒
	}
}

Breakpoint 8:runtime.mstart

在這裡插入圖片描述

runtime.mstart新建並啟動M,開始排程goroutine,到此,彙編引導全部完成,剩下的由golang實現。對於啟動過程來說,第一個被排程的goroutine就是runtime.main()協程本程了,函式呼叫鏈如下:

runtime.mstart -> runtime.mstart1 -> schedule() -> execute() -> gogo() -> runtime.main()

這裡我們不深究golang協程排程器如何實現,只關心runtime.main做了什麼。具體如下總結就是限定棧大小、將主goroutine鎖定到主OS執行緒上、啟動gc相關的協程、執行所有系統庫相關包內和使用者自定義的init函式,然後就進入使用者main函式

runtime.main程式碼如下:

// The main goroutine.
func main() {
	g := getg()
	// 棧大小上限: 1 GB on 64-bit, 250 MB on 32-bit.
	if sys.PtrSize == 8 {
		maxstacksize = 1000000000
	} else {
		maxstacksize = 250000000
	}

	// 允許 newproc 開始新的 M.
	mainStarted = true

	// 將主goroutine鎖定到主OS執行緒上
	lockOSThread()

	// 執行runtime包內所有init函式
	doInit(&runtime_inittask) 

	// 啟動了bgsweep goroutine和scavenger goroutine,負責gc
	// bgsweep 負責垃圾回收
	// scavenger 負責mhead(堆記憶體)的回收
	gcenable()
	// 執行使用者程式碼的init函式
	doInit(&main_inittask)

	needUnlock = false
	unlockOSThread()

	// 執行main_main,即我們編寫的main函式
	fn := main_main
	fn() // 執行main_main,即執行到了Breakpoint 9
	// 呼叫runtime.exit,退出程式
	exit(0)
}

Breakpoint 9:main.main

在這裡插入圖片描述

現在,正式進入我們寫的main函式了,main所在gorutine獲得cpu執行許可權,PID為31678,至此main啟動完成

Breakpoint 10:runtime.exit

在這裡插入圖片描述

最終,main執行結束,返回runtime.mstart,在runtime.mstart呼叫runtime.exit結束程式;

2. 即問即答

過程走完了,有點亂,通過下面幾個問題我們來整理一下…

2.1 go程式的大致啟動過程

答:如我們除錯過程所示,斷點的設定即代表了go啟動的大致過程。

2.2 一個go程式預設到底是幾個goroutines?分別承擔什麼角色?

答:預設5個執行緒,角色如下:

main:使用者主協程
forcegchelper:監控計時器觸發垃圾回收
bgsweep: 負責垃圾回收的併發執行
scavenger: 負責mhead(堆記憶體)的回收(真正執行記憶體釋放操作?)
finalizer: 專門執行最終附加到物件的所有終結器(Finalizer)

可以逐步除錯runtime.main程式碼,通過info goroutinesgoroutine [gid] bt檢視協程資訊

2.3 涉及到的重點檔案或重點function?分別實現了什麼功能?

function位置功能
_rt0_amd64_linuxruntime.rt0_linux_amd64.s入口檔案
_rt0_amd64runtime.asm_amd64.samd64系統的常用啟動程式碼,go程式的一生都展示在這個function裡了
func check()runtime.runtime1.goCPU 相關的特性標誌位檢查的程式碼,以及對一些型別大小進行檢查等(比如我們不同字長機器上每種型別的位元組大小的確定就是在here了,簡單理解為適配機器)
func args(c int32, v **byte)runtime.runtime1.go將引數(argc 和 argv )儲存到靜態變數中
func osinit()runtime.os_linux.go初始化了 CPU 邏輯核數和記憶體頁大小
func schedinit()runtime.proc.go初始化排程器的事,包括:初始化命令列引數、環境變數、gc、棧空間、記憶體管理、P 例項、HASH演算法等
func newproc(siz int32, fn *funcval)runtime.proc.go新建協程(即執行程式碼go func()的入口,fn為該協程的函式地址)
func mstart()runtime.proc.gomstart是新M的入點(每個M有一個g0進行排程等工作)
runtime.mainruntime.proc.go限定棧大小、將主goroutine鎖定到主OS執行緒上、啟動gc相關協程、執行系統庫相關包和使用者自定義的init函式、執行使用者main函式

2.4 進階:gorutine 的"一生"

未完待續…

相關文章