[go runtime] - go程序启动过程

3,931 阅读8分钟

本文主要是通过GDB对go语言程序进行调试,探究go程序的启动过程。

总览

go语言具有内存管理、并发调度、垃圾回收等特性,go语言在编译阶段会在用户的主函数中插入引导程序,即go语言的入口函数不是用户的main.main(),而是runtime/rt0_linux_amd64.s的_rt0_amd64_linux(SB)。go语言通过引导程序来实现对命令行参数的读取、对环境变量的读取、运行环境的检查、内存管理的初始化、调度器的初始化、垃圾回收器的初始化等操作,其具体流程如图所示。

go程序的启动,首先进行了运行时类型的检查;然后读取了两个常用的运行时常量,即处理器核心数和内存物理页大小;后续,进行了调度器、内存空间、命令行参数、系统环境变量、垃圾收集器的初始化,其中调度器根据处理器核心数进行了处理器process数量的选取;随后,通过创建了新的G并对其进行调度,来执行runtime.main函数;在runtime.main函数中,进行了runtime包init方法的执行、垃圾收集器的启动、用户包init方法的执行、用户主函数的执行。

go启动流程图.jpg

准备

  1. 相关软件
linux
gdb
go -> go1.7.4 linux/amd64
  1. 示例程序
// test.go
package main

func main() {
    println("Hello World!")
}
  1. 编译程序
# -o 指定输出文件路径
# -gcflags "-N" 关闭编译器代码优化 disable optimizations
# -gcflags "-l" 关闭函数内联 disable inlining
go build -gcflags "-N -l" -o test test.go
  1. 使用GDB调试
gdb -tui ./test
# gdb常用命令如下:
# b 100 在第100行设置断点
# run 运行程序
# c 运行到下一个断点
# step 步进,进入函数
# next 运行下一行代码

启动过程

通过gdb调试,发现go语言的程序入口在runtime/rt0_linux_amd64.s _rt0_amd64_linux(SB),之后会跳转到runtime/asm_amd64.s runtime·rt0_go(SB)

  • runtime/asm_amd64.s
TEXT runtime·rt0_go(SB),NOSPLIT,$0
        // 复制参数数量argc和参数值argv到栈上
        MOVQ    DI, AX          // argc
        MOVQ    SI, BX          // argv
        SUBQ    $(4*8+7), SP            // 2args 2auto
        ANDQ    $~15, SP
        MOVQ    AX, 16(SP)
        MOVQ    BX, 24(SP)
        
        // 初始化 g0 执行栈
        MOVQ    $runtime·g0(SB), DI	    // DI = g0
        LEAQ    (-64*1024+104)(SP), BX
        MOVQ    BX, g_stackguard0(DI)	    // g0.stackguard0 = SP + (-64*1024+104)
        MOVQ    BX, g_stackguard1(DI)       // g0.stackguard1 = SP + (-64*1024+104)
        MOVQ    BX, (g_stack+stack_lo)(DI)  // g0.stackguard1 = SP + (-64*1024+104)
        MOVQ    SP, (g_stack+stack_hi)(DI)  // g0.stackguard1 = SP + (-64*1024+104)
        ...
        // 该函数在 runtime/runtime1.go/check(),进行各种检查,包括类型的长度Sizeof、结构体字段的偏移量、
        // CAS操作、指针操作、原子操作、汇编指令、栈大小检查等
        CALL    runtime·check(SB)
        ...
        // 该函数在runtime/runtime1.go/args(c int32, v **byte) 进行命令行参数的初始化
        CALL    runtime·args(SB)      
        // 该函数在runtime/os_linux.go/osinit() 读取操作系统的CPU核数
        CALL    runtime·osinit(SB)    
        // 该函数在runtime/proc.go/schedinit() 调度器的初始化,涉及内存空间的初始化、命令行参数的初始化、
        // 垃圾收集器参数的初始化、调度器process的设置等
        CALL    runtime·schedinit(SB) 
        // create a new goroutine to start program
        MOVQ    $runtime·mainPC(SB), AX         // entry
        PUSHQ   AX
        PUSHQ   $0                      // arg size
        // 该函数在runtime/proc.go/newproc(siz int32, fn *funcval) 
        // 创建一个新的G,并将G放到runtime的队列中,这个G用于执行runtime.main函数
        CALL    runtime·newproc(SB)  
        
        POPQ    AX
        POPQ    AX

        // start this M
        // runtime/proc.go/mstart() 开始调度,从队列里面取G进行执行,并执行runtime.main函数
        // 在runtime.main的执行中,会依次执行runtime中的init函数、启动GC收集器、
        // 执行用户包的init函数、执行用户的main函数
        CALL    runtime·mstart(SB)

        MOVL    $0xf1, 0xf1  // crash
        RET

参数初始化runtime.args

  • 这里主要是对命令行中的参数进行处理,将参数数量赋值给argc int32,将参数值复制给argv **byte供相关函数使用。
  • mac采用mach-o格式的可执行文件,mac系统的args(c int32, v **byte)函数处理比较简单,只需要将命令的执行路径赋值给os_darwin.goexecutablePath变量,executablePath变量通过go:linkname将值赋给os.executablePath
  • linux系统的相对复杂,涉及到ELF格式,可以参考golang.design/under-the-h…
// runtime/runtime1.go
var (
	argc int32
	argv **byte
)

func args(c int32, v **byte) {
	argc = c
	argv = v
	sysargs(c, v) // runtime/os_linux.go:sysargs(c, v)
}

系统初始化runtime.osinit

  • 这里主要是通过sched_getaffinity获取当前进程绑定的CPU个数,这里涉及CPU的亲和性,可以参考:zhuanlan.zhihu.com/p/57470627
// runtime/os_linux.go/osinit()
var ncpu int32

func osinit() {
    // 获取CPU核数
    ncpu = getproccount()
}

func getproccount() int32 {
    const maxCPUs = 64 * 1024
    var buf [maxCPUs / (sys.PtrSize * 8)]uintptr
    // 通过sched_getaffinity获取当前进程的CPU亲和力
    r := sched_getaffinity(0, unsafe.Sizeof(buf), &buf[0])
    n := int32(0)
    for _, v := range buf[:r/sys.PtrSize] {
            for v != 0 {
                    n += int32(v & 1)
                    v >>= 1
            }
    }
    if n == 0 {
            n = 1
    }
    return n
}

调度器初始化runtime.schedinit

  • 这里进行调取器的初始化,主要涉及到执行栈空间、堆内存空间、hash算法、命令行参数、环境变量、GC垃圾收集器参数、调度器processor数量的初始化。值得注意的是,这里仅设置垃圾收集器的参数,并没有启动垃圾收集器。垃圾收集器的启动是在runtime.main函数中进行的。
// runtime/proc.go/schedinit()
func schedinit() {
    
    _g_ := getg()
    if raceenabled {
        // 启动go的并发竞争探测器,具体用法见https://blog.golang.org/race-detector
        _g_.racectx, raceprocctx0 = raceinit() 
    }

    sched.maxmcount = 10000  // 最大系统线程数量

    tracebackinit()
    moduledataverify()
    /**
    执行栈初始化, runtime/stack.go/stackinit(),涉及到go的栈空间管理
    主要将runtime/stack.go的stackpool和stackLarge进行初始化
    这两个变量采用MSpanList进行内存管理
    */
    stackinit()
    /**
    进行堆内存的初始化(内存分配器)的初始化
    同时给当前G分配一个mcache
    runtime/malloc.go/mallocinit()
    */
    mallocinit()
    /**
    初始化g的系统线程
    https://www.cnblogs.com/abozhang/p/10813229.html
    */
    mcommoninit(_g_.m)
    /**
    根据CPU设置各种hash摘要算法,主要用于map的hash
    参考https://studygolang.com/articles/7211
    */
    alginit()       // maps must not be used before this call
    /**
    涉及到外部包的映射
    */
    typelinksinit() // uses maps
    /**
    应该类型相关的初始化
    */
    itabsinit()
    /**
    设置信号屏蔽
    */
    msigsave(_g_.m)
    initSigmask = _g_.m.sigmask
    /**
    通过go:linkname加载命令行参数到os.Args
    */
    goargs()
    /**
    通过go:linkname加载操作系统env变量os.Environ()
    */
    goenvs()
    /**
    解析go环境变量中的“GODEBUG”和“GOTRACEBACK”,并设置runtime的debug相关变量
    */
    parsedebugvars()
    /**
    垃圾回收器的初始化,这里仅设置参数,并没有启动垃圾收集器
    垃圾收集器的启动在runtime.main函数中进行
    */
    gcinit()
    /**
    设置MPG模型里面的procs数量,依据_MaxGomaxprocs、GOMAXPROCS环境变量、当前进程绑定的ncpu数量
    */
    sched.lastpoll = uint64(nanotime())
    procs := int(ncpu)
    if procs > _MaxGomaxprocs {
    procs = _MaxGomaxprocs
    }
    if n := atoi(gogetenv("GOMAXPROCS")); n > 0 {
    if n > _MaxGomaxprocs {
            n = _MaxGomaxprocs
    }
    procs = n
    }
    /**
    设置调度器的proce的数量, STW操作
    */
    if procresize(int32(procs)) != nil {
    throw("unknown runnable goroutine during bootstrap")
    }

    if buildVersion == "" {
    // Condition should never trigger. This code just serves
    // to ensure runtime·buildVersion is kept in the resulting binary.
    buildVersion = "unknown"
    }
}

主函数调度runtime·newproc和runtime.mstart

  • 这里主要是通过runtime/proc.go/newproc创建一个G,用于执行runtime.main,并将其加入到队列中。
  • 然后调用runtime/proc.go/mstart开始进行调度,执行队列里面的runtime.main的G
/**
runtime/proc.go/newproc(siz int32, fn *funcval)
siz表示fn函数参数的bytes大小
fn表示需要G执行的函数
在启动场景中,fn是runtime/proc.go:runtime.main(),所以siz为0
这里的操作主要是,创建一个新的g,用于执行runtime.main;创建完成后,将新的g放到队列中,等待被调度执行。
*/
func newproc(siz int32, fn *funcval) {
    argp := add(unsafe.Pointer(&fn), sys.PtrSize)
    pc := getcallerpc(unsafe.Pointer(&siz))
    systemstack(func() {
        // runtime/proc.go/newproc1(fn *funcval, argp *uint8, narg int32, nret int32, callerpc uintptr) *g 
        newproc1(fn, (*uint8)(argp), siz, 0, pc)
    })
}

/**
runtime/proc.go/mstart()
进行调度
*/
func mstart() {
    _g_ := getg()

    ...
    mstart1()
}


func mstart1() {
    _g_ := getg()

    ...
    asminit()
    minit()     //runtime/os_linux.go/minit() 初始化m的sigal的栈和mask

    ...
    schedule()  // 开始调度执行,最终会执行runtime/proc.go/main()
}

// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
    _g_ := getg()
    
    ...
    execute(gp, inheritTime) // 在这里会执行runtime.main
}

主函数执行runtime.main

runtime.main的G被调度之后,依次进行了runtime包内init方法的执行、GC垃圾收集器的启动、用户包init方法的执行、用户main主函数的执行。

// The main goroutine.
func main() {
    g := getg()

    ...
    // 执行栈最大限制:1GB(64位系统)或者 250MB(32位系统)
    if sys.PtrSize == 8 {
        maxstacksize = 1000000000
    } else {
        maxstacksize = 250000000
    }
    ...

    // 启动系统后台监控(定期垃圾回收、抢占调度等等)
    systemstack(func() {
        newm(sysmon, nil)
    })

    ...
    // 让goroute独占当前线程, 
    // runtime.lockOSThread的用法详见http://xiaorui.cc/archives/5320
    lockOSThread()

    ...
    // runtime包内部的init函数执行
    runtime_init() // must be before defer

    // Defer unlock so that runtime.Goexit during init does the unlock too.
    needUnlock := true
    defer func() {
        if needUnlock {
                unlockOSThread()
        }
    }()
    // 启动GC
    gcenable()

    ...
    // 用户包的init执行
    main_init()
    ...

    needUnlock = false
    unlockOSThread()

    ...
    // 执行用户的main主函数
    main_main()
    
    ...
    // 退出
    exit(0)
    for {
        var x *int32
        *x = 0
    }
}

结论

通过对go程序启动的分析,Go语言并不是直接执行用户的main函数,而是在执行用户的main函数之前进行了go程序运行时的初始化与启动,包括调度器、栈空间、内存分配器、垃圾收集器等组件的初始化和启动,最后通过newprocmstart调度执行runtime.main,并执行用户的主函数。
由此可见,内存分配器、垃圾回收器、调度器是go运行时的主要组成部分。

参考