main的启动过程

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 的"一生"

未完待续…

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值