查看 ELF 二进制文件结构:
可以通过 readelf 命令查看 ELF 二进制文件的结构,可以看到二进制文件中代码区和数据区的内容,全局变量保存在数据区,函数保存在代码区
$ readelf -s main | grep runtime.g0
1765: 000000000054b3a0 376 OBJECT GLOBAL DEFAULT 11 runtime.g0
// _cgo_init 为全局变量
$ readelf -s main | grep -i _cgo_init
2159: 000000000054aa88 8 OBJECT GLOBAL DEFAULT 11 _cgo_init
经上述几个步骤生成可执行文件后,二进制文件在被操作系统加载起来运行时会经过如下几个阶段:
通过一个简单的go程序单步调试来分析其启动过程的流程
main.go
package main
import "fmt"
func main() {
fmt.Println("hello world")
}
编译该程序并使用 gdb 进行调试,使用gdb调试时首先在程序入口处设置一个断点,然后进行单步调试即可看到该程序启动过程中的代码执行流程
$ go build -gcflags "-N -l" -o main main.go
$ gdb ./main
(gdb) info files
Symbols from "/home/gosoon/main".
Local exec file:
`/home/gosoon/main', file type elf64-x86-64.
Entry point: 0x465860
0x0000000000401000 - 0x0000000000497893 is .text
0x0000000000498000 - 0x00000000004dbb65 is .rodata
0x00000000004dbd00 - 0x00000000004dc42c is .typelink
0x00000000004dc440 - 0x00000000004dc490 is .itablink
0x00000000004dc490 - 0x00000000004dc490 is .gosymtab
0x00000000004dc4a0 - 0x0000000000534b90 is .gopclntab
0x0000000000535000 - 0x0000000000535020 is .go.buildinfo
0x0000000000535020 - 0x00000000005432e4 is .noptrdata
0x0000000000543300 - 0x000000000054aa70 is .data
0x000000000054aa80 - 0x00000000005781f0 is .bss
0x0000000000578200 - 0x000000000057d510 is .noptrbss
0x0000000000400f9c - 0x0000000000401000 is .note.go.buildid
(gdb) b *0x465860
Breakpoint 1 at 0x465860: file /home/gosoon/golang/go/src/runtime/rt0_linux_amd64.s, line 8.
(gdb) r
Starting program: /home/gaofeilei/./main
Breakpoint 1, _rt0_amd64_linux () at /home/gaofeilei/golang/go/src/runtime/rt0_linux_amd64.s:8
8 JMP _rt0_amd64(SB)
(gdb) n
_rt0_amd64 () at /home/gaofeilei/golang/go/src/runtime/asm_amd64.s:15
15 MOVQ 0(SP), DI // argc
(gdb) n
16 LEAQ 8(SP), SI // argv
(gdb) n
17 JMP runtime·rt0_go(SB)
(gdb) n
runtime.rt0_go () at /home/gaofeilei/golang/go/src/runtime/asm_amd64.s:91
91 MOVQ DI, AX // argc
......
231 CALL runtime·mstart(SB)
(gdb) n
hello world
[Inferior 1 (process 39563) exited normally]
通过单步调试可以看到程序入口函数在
runtime/rt0_linux_amd64.s
文件中的第 8 行,最终会执行CALL runtime·mstart(SB)
指令后输出 “hello world” ,然后程序就退出了,启动流程中的函数调用如下所示:
rt0_linux_amd64.s -->_rt0_amd64 --> rt0_go-->runtime·settls -->runtime·check-->runtime·args-->runtime·osinit-->runtime·schedinit-->runtime·newproc-->runtime·mstart
这里解释一下文件名:
- rt0 : runtime0 表示起始运行时
linux
: 操作系统 我这里是linux系统- amd64 : 操作系统架构,对应(GOHOSTARCH)
- 启动文件位于GOROOT/src/runtime目录下,那同理可以看到其他系统的启动文件
看一下这个启动文件干了嘛
src/runtime/rt0_linux_amd64.s
#include "textflag.h"
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
JMP _rt0_amd64(SB)
TEXT _rt0_amd64_linux_lib(SB),NOSPLIT,$0
JMP _rt0_amd64_lib(SB)
首先执行的第8行即
JMP _rt0_amd64
,此处在 amd64 平台下运行,_rt0_amd64
函数所在的文件为src/runtime/asm_amd64.s
TEXT _rt0_amd64(SB),NOSPLIT,$-8
// 处理 argc 和 argv 参数,argc 是指命令行输入参数的个数,argv 存储了所有的命令行参数
MOVQ 0(SP), DI // argc
// argv 为指针类型
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)
_rt0_amd64
函数中将 argc 和 argv 两个参数保存到 DI 和 SI 寄存器后跳转到了rt0_go
函数,rt0_go
函数的主要作用如下:
- 1、将 argc、argv 参数拷贝到主线程栈上;
- 2、初始化全局变量 g0,为 g0 在主线程栈上分配大约 64K 栈空间,并设置 g0 的stackguard0,stackguard1,stack 三个字段;
- 3、执行 CPUID 指令,探测 CPU 信息;
- 4、执行 nocpuinfo 代码块判断是否需要初始化 cgo;
- 5、执行 needtls 代码块,初始化 tls 和 m0;
- 6、执行 ok 代码块,首先将 m0 和 g0 绑定,然后:
- 调用
runtime.args
函数处理进程参数和环境变量- 调用
runtime.osinit
函数初始化 cpu 数量- 调用
runtime.schedinit
初始化调度器- 调用
runtime.newproc
创建第一个 goroutine 执行 main 函数- 调用
runtime.mstart
启动主线程,主线程会执行第一个 goroutine 来运行 main 函数,此处会阻塞住直到进程退出参数拷贝,初始化全局变量代码大致如下:
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
// 处理命令行参数的代码
MOVQ DI, AX // AX = argc
MOVQ SI, BX // BX = argv
// 将栈扩大39字节,此处为什么扩大39字节暂时还没有搞清楚
SUBQ $(4*8+7), SP
ANDQ $~15, SP // 调整为 16 字节对齐
MOVQ AX, 16(SP) //argc放在SP + 16字节处
MOVQ BX, 24(SP) //argv放在SP + 24字节处
// 开始初始化 g0,runtime·g0 是一个全局变量,变量在 src/runtime/proc.go 中定义,全局变量会保存在进程内存空间的数据区,下文会介绍查看 elf 二进制文件中的代码数据和全局变量的方法
// g0 的栈是从进程栈内存区进行分配的,g0 占用了大约 64k 大小。
MOVQ $runtime·g0(SB), DI // g0 的地址放入 DI 寄存器
LEAQ (-64*1024+104)(SP), BX // BX = SP - 64*1024 + 104
// 开始初始化 g0 对象的 stackguard0,stackguard1,stack 这三个字段
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.stack.lo = SP - 64*1024 + 104
MOVQ SP, (g_stack+stack_hi)(DI) // g0.stack.hi = SP
rt0_go
可分为两个部分,第一部分是系统参数获取和运行时检查,第二部分是go程序启动的核心, 这里就是整个go代码的起点,,这里只详细介绍第二部分,执行完以上指令后,进程内存空间布局如下所示:
然后开始执行获取 cpu 信息的指令以及与 cgo 初始化相关的指定:
// 执行CPUID指令,尝试获取CPU信息,探测 CPU 和 指令集的代码
MOVL $0, AX
CPUID
MOVL AX, SI
CMPL AX, $0
JE nocpuinfo
// Figure out how to serialize RDTSC.
// On Intel processors LFENCE is enough. AMD requires MFENCE.
// Don't know about the rest, so let's do MFENCE.
CMPL BX, $0x756E6547 // "Genu"
JNE notintel
CMPL DX, $0x49656E69 // "ineI"
JNE notintel
CMPL CX, $0x6C65746E // "ntel"
JNE notintel
MOVB $1, runtime·isIntel(SB)
MOVB $1, runtime·lfenceBeforeRdtsc(SB)
notintel:
// Load EAX=1 cpuid flags
MOVL $1, AX
CPUID
MOVL AX, runtime·processorVersionInfo(SB)
nocpuinfo:
// cgo 初始化相关,_cgo_init 为全局变量
MOVQ _cgo_init(SB), AX
// 检查 AX 是否为 0
TESTQ AX, AX
// 跳转到 needtls
JZ needtls
// arg 1: g0, already in DI
MOVQ $setg_gcc<>(SB), SI // arg 2: setg_gcc
CALL AX
// 如果开启了 CGO 特性,则会修改 g0 的部分字段
MOVQ $runtime·g0(SB), CX
MOVQ (g_stack+stack_lo)(CX), AX
ADDQ $const__StackGuard, AX
MOVQ AX, g_stackguard0(CX)
MOVQ AX, g_stackguard1(CX)
下面执行
needtls
代码块,初始化 tls 和 m0,tls 为线程本地存储,在 golang 程序运行过程中,每个 m 都需要和一个工作线程关联,那么工作线程如何知道其关联的 m,此时就会用到线程本地存储,线程本地存储就是线程私有的全局变量,通过线程本地存储可以为每个线程初始化一个私有的全局变量 m,然后就可以在每个工作线程中都使用相同的全局变量名来访问不同的 m 结构体对象。后面会分析到其实每个工作线程 m 在刚刚被创建出来进入调度循环之前就利用线程本地存储机制为该工作线程实现了一个指向 m 结构体实例对象的私有全局变量。在后面代码分析中,会经常看到调用
getg
函数,getg
函数会从线程本地存储中获取当前正在运行的 g,这里获取出来的 m 关联的 g0。// 下面开始初始化tls(thread local storage,线程本地存储),设置 m0 为线程私有变量,将 m0 绑定到主线程 needtls: LEAQ runtime·m0+m_tls(SB), DI //DI = &m0.tls,取m0的tls成员的地址到DI寄存器 // 调用 runtime·settls 函数设置线程本地存储,runtime·settls 函数的参数在 DI 寄存器中 // 在 runtime·settls 函数中将 m0.tls[1] 的地址设置为 tls 的地址 // runtime·settls 函数在 runtime/sys_linux_amd64.s#599 CALL runtime·settls(SB) // 此处是在验证本地存储是否可以正常工作,确保值正确写入了 m0.tls, // 如果有问题则 abort 退出程序 // get_tls 是宏,位于 runtime/go_tls.h get_tls(BX) // 将 tls 的地址放入 BX 中,即 BX = &m0.tls[1] MOVQ $0x123, g(BX) // BX = 0x123,即 m0.tls[0] = 0x123 MOVQ runtime·m0+m_tls(SB), AX // AX = m0.tls[0] CMPQ AX, $0x123 JEQ 2(PC) // 如果相等则向后跳转两条指令即到 ok 代码块 CALL runtime·abort(SB) // 使用 INT 指令执行中断
然后继续执行ok 代码块,主要逻辑为:
- 将 m0 和 g0 进行绑定,启动主线程
- 调用
runtime.osinit
函数用来初始化 cpu 数量,调度器初始化时需要知道当前系统有多少个CPU核- 调用
runtime.schedinit
函数会初始化m0和p对象,还设置了全局变量 sched 的 maxmcount 成员为10000,限制最多可以创建10000个操作系统线程出来工作- 调用
runtime.newproc
为main函数创建 goroutine- 调用
runtime.mstart
启动主线程,执行 main 函数
// 首先将 g0 地址保存在 tls 中,即 m0.tls[0] = &g0,然后将 m0 和 g0 绑定
// 即 m0.g0 = g0, g0.m = m0
ok:
get_tls(BX) // 获取tls地址到BX寄存器,即 BX = m0.tls[0]
LEAQ runtime·g0(SB), CX // CX = &g0
MOVQ CX, g(BX) // m0.tls[0]=&g0
LEAQ runtime·m0(SB), AX // AX = &m0
MOVQ CX, m_g0(AX) // m0.g0 = g0
MOVQ AX, g_m(CX) // g0.m = m0
CLD // convention is D is always left cleared
// check 函数检查了各种类型以及类型转换是否有问题,位于 runtime/runtime1.go#137 中
CALL runtime·check(SB)
// 将 argc 和 argv 移动到 SP+0 和 SP+8 的位置
// 此处是为了将 argc 和 argv 作为 runtime·args 函数的参数
MOVL 16(SP), AX
MOVL AX, 0(SP)
MOVQ 24(SP), AX
MOVQ AX, 8(SP)
// args 函数会从栈中读取参数和环境变量等进行处理
// args 函数位于 runtime/runtime1.go#61
CALL runtime·args(SB)
// osinit 函数用来初始化 cpu 数量,函数位于 runtime/os_linux.go#301
CALL runtime·osinit(SB)
// schedinit 函数用来初始化调度器,函数位于 runtime/proc.go#654
CALL runtime·schedinit(SB)
// 创建第一个 goroutine 执行 runtime.main 函数。获取 runtime.main 的地址,调用 newproc 创建 g
MOVQ $runtime·mainPC(SB), AX
PUSHQ AX // runtime.main 作为 newproc 的第二个参数入栈
PUSHQ $0 // newproc 的第一个参数入栈,该参数表示runtime.main函数需要的参数大小,runtime.main没有参数,所以这里是0
// newproc 创建一个新的 goroutine 并放置到等待队列里,该 goroutine 会执行runtime.main 函数, 函数位于 runtime/proc.go#4250
CALL runtime·newproc(SB)
// 弹出栈顶的数据
POPQ AX
POPQ AX
// mstart 函数会启动主线程进入调度循环,然后运行刚刚创建的 goroutine,mstart 会阻塞住,除非函数退出,mstart 函数位于 runtime/proc.go#1328
CALL runtime·mstart(SB)
CALL runtime·abort(SB) // mstart should never return
RET
// Prevent dead-code elimination of debugCallV2, which is
// intended to be called by debuggers.
MOVQ $runtime·debugCallV2<ABIInternal>(SB), AX
RET
此时进程内存空间布局如下所示:
总体启动流程大致如下:
上面的启动流程中运用了runtime包里面的一下方法,这里拿出来分析一下
check
函数位于runtime的runtime1.go
中,主要是检查一些标识
args
函数同样runtime的runtime1.go
中
var (
argc int32 //参数个数
argv **byte //入参
)
func args(c int32, v **byte) { //初始全局变量 argc,argv 并调用sysargs
argc = c
argv = v
sysargs(c, v)
}
var executablePath string
//获取执行程序路径 复制到全局变量executablePath
func sysargs(argc int32, argv **byte) {
。。。
}
schedinit
位于runtime的proc.go
文件中,它的功能是进行各种运行时额所有核心组件初始化工作,这包括调度器与内存分配器、回收器的初始化
func schedinit() {
//lockInit 锁相关的初始化 暂时忽略
//获取当前的g 之前已经保存在tls中了,getg就是从tls中获取
//大致的关系是fs -> tls[1] -> g() -> tls[0] -> g0 -> g0.m0 = &m0 -> m0.g0 = &g0
//从fs段寄存器出发 找到 m0.tls[1] ,地址-8后得到 tls[0] 而 tls[0]正好指向g0获取到
_g_ := getg()
if raceenabled { //如果启用了race 则进行raceinit的初始化,默认false
_g_.racectx, raceprocctx0 = raceinit()
}
//默认m(线程)的最大值是10000个,面试经常问
sched.maxmcount = 10000
// The world starts stopped.
worldStopped() //用于lock rank,可忽略
moduledataverify() //验证链接器符号,可忽略
//初始栈,就是初始 stackLarge,stackpool 两个全局变量。对这哥俩感兴趣的可以看上篇博文 内存管理
//注意这里还没有给栈分配内存
stackinit()
//内存分配初始化。就是计算内存大小,初始化mheap,mcache0 等操作
mallocinit()
//初始化CPU相关的参数
//读取环境变量GODEBUG,并调用 internal/cpu.Initialize
cpuinit() // must run before alginit
//map使用必须调用,算法相关
alginit() // maps, hash, fastrand must not be used before this call
//随机数相关
fastrandinit() // must run before mcommoninit
//初始化m,调用atomicstorep将m0放入全局变量allm
//并且将allm挂到m的alllink上
mcommoninit(_g_.m, -1)
//模块初始化,将所有模块的moduledata的gc标志初始化,并将moduledata放入全局变量modulesSlice中
modulesinit() // provides activeModules
//type这种别名相关的,消除重复映射
typelinksinit() // uses maps, activeModules
//接口相关,将每个模块的itab 放入全局变量itabTable.entries中,方便动态派发
//itab粗糙的理解 = 接口类型+具体实现类型,方便动态类型的查找。
itabsinit() // uses activeModules
//初始化methodValueCallFrameObjs栈对象
stkobjinit() // must run before GC starts
//将当前线程信号保存到m.sigmask中,一并设置到全局变量initSigmask
sigsave(&_g_.m.sigmask)
initSigmask = _g_.m.sigmask
...
goargs() //入参全局变量argslice初始化
goenvs() //环境全局变量envs初始化
parsedebugvars() //初始化debug包变量,并根据环境变量GODEBUG解析dbgvars的一系列配置
gcinit() //gc相关
lock(&sched.lock)
//sched.lastpoll 设置调度器初始化轮训时间
sched.lastpoll = uint64(nanotime())
//设置当前cpu个数,在 osinit() 函数里已经获取到。如果环境变量GOMAXPROCS设置了CPU个数,直使用设置个数。
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
//调整cpu 数量
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}
unlock(&sched.lock)
...
// World is effectively started now, as P's can run.
worldStarted()
}
newproc
//创建一个新的g,绑定main函数,并且加入到队列中等待执行
func newproc(fn *funcval) {
gp := getg() //获取当前g
pc := getcallerpc() //获取程序计数器
systemstack(func() {
//创建新的g并绑定fn,也就是main
newg := newproc1(fn, gp, pc)
_p_ := getg().m.p.ptr()
//推入p的队列中
runqput(_p_, newg, true)
//是否启动M开始执行
//默认为false,在下面的main函数中设置mainStarted=true,所以第一次到这里是不会执行的。
if mainStarted {
wakep()
}
})
}
main
函数,同样在这个文件里:
func main() {
g := getg()
g.m.g0.racectx = 0
//设置栈的最大值,按处理器位数,64位对应1G,32位对应250MB
if goarch.PtrSize == 8 {
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
}
maxstackceiling = 2 * maxstacksize
mainStarted = true //允许上面的newproc函数创建Ms
...
//执行每runtime的init
doInit(&runtime_inittask) // Must be before defer.
...
gcenable() //开启gc
//下面一大坨都是cgo相关
main_init_done = make(chan bool)
if iscgo {
...
}
doInit(&main_inittask) //执行package main的init
...
fn := main_main // 执行package main中主函数
fn()
...
exit(0) //退出进程
}
这里有一个
doInit
函数:它会执行每个模块中的init函数,init函数对应结构体如下:
type initTask struct {
state uintptr //状态标识 0:未执行, 1:执行中, 2:已完成
ndeps uintptr //当前模块的其他依赖
nfns uintptr //模块里面的几个init函数
}
看这个结构就可以指定,所有的init函数会根据模块的依赖关系形成一个,执行的过程就是对这个图进行深度优先遍历,遍历函数
doInit
如下:
func doInit(t *initTask) {
switch t.state {
case 2: // 完成退出
return
case 1: // 异常panic
throw("recursive call during initialization - linker skew")
default: // 遍历执行
t.state = 1 // 先设置状态到执行中
//向下递归
for i := uintptr(0); i < t.ndeps; i++ {
p := add(unsafe.Pointer(t), (3+i)*goarch.PtrSize)
t2 := *(**initTask)(p)
doInit(t2)
}
//当前模块没init则设置状态到完成,返回
if t.nfns == 0 {
t.state = 2 // initialization done
return
}
... //执行当前模块的init,完成后设置状态2 返回
t.state = 2
}
}
mstart
函数汇编中指向mstart0,它
在proc.go文件里,它的功能是开始启动调度器的调度循环,以此来启动线程,启动调度系统. 执行队列中入口方法是runtime.main
的 G
TEXT runtime·rt0_go(SB),NOSPLIT,$0
(...)
// 调度器初始化
CALL runtime·schedinit(SB)
// 创建一个新的 goroutine 来启动程序
MOVQ $runtime·mainPC(SB), AX
PUSHQ AX
PUSHQ $0 // 参数大小
CALL runtime·newproc(SB)
POPQ AX
POPQ AX
// 启动这个 M,mstart 应该永不返回
CALL runtime·mstart(SB)
(...)
RET
func mstart0() {
...
mstart1() // 启动m
//退出当前线程
if mStackIsSystemAllocated() {
osStack = true
}
//执行完所有的 Goroutine 后,清理并退出m,不会执行到这里
mexit(osStack)
}
func mstart1() {
...
asminit()
minit() //初始化新的m,在新线程上调用
...
schedule() //开始调度,找到一个`runnable`状态的goroutine并执行
}
其中,schedule 是整个 golang 程序的运行核心,所有的协程都是通过它来开始运行的,schedule 的主要工作逻辑如下:
- 每隔 61次调度轮回从全局队列找,避免全局队列中的g被饿死
- 从 p.runnext 获取 g,从 p的本地队列中获取
- 调用 findrunnable 找 g,找不到的话就将 m 休眠,等待唤醒
- 当找到一个 g 后,就会调用 execute 去执行 g
源码如下:
// file:runtime/proc.go
func schedule() {
_g_ := getg()
...
top:
pp := _g_.m.p.ptr()
//每 61 次从全局运行队列中获取可运行的协程
if gp == nil {
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)
unlock(&sched.lock)
}
}
if gp == nil {
//从当前 P 的运行队列中获取可运行
gp, inheritTime = runqget(_g_.m.p.ptr())
}
if gp == nil {
//当前P或者全局队列中获取可运行协程
//尝试从其它P中steal任务来处理
//如果获取不到,就阻塞
gp, inheritTime = findrunnable() // blocks until work is available
}
//执行协程
execute(gp, inheritTime)
}
好了,runtime几个核心的方法讲解了,接下来看看main函数的真正运行
通过上面的步骤以及runtime函数的讲解,知道了整个golang的调度系统,以及设置了runtime.main函数作为入口,所以对于主协程的调度,就会进入这个入口进行执行,通过runtime 运行自己写的main函数,其实runtime.main 在执行main包中的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
}
}
到这里,用户定义的 main 函数能被执行到,就可以输出用户的程序了
Golang 程序的运行入口是runtime定义的一个汇编函数_rt0_amd64,这个函数核心有三个:
当调度器开始执行后,其中主协程会进入 runtime.main 函数中运行,在这个函数中进行初始化后,最后真正进入用户的 main 中运行
参考: