package main
import "fmt"
func main() {
fmt.Println("Hello World!")
}
通过跟踪上述经典案例从启动到退出的完整运行流程,来分析Go调度器的初始化,以及goroutine的创建和退出,还有工作线程的调度循环和goroutine的切换等内容。
先从程序开始启动时分析调度器初始化。
在此之前先看下程序在执行第一条指令之前栈的初始状态。
任何由编译型语言(C、C++、Go以及汇编语言)编写的程序在被操作系统加载执行的顺序都会经过如下几个阶段:
-
从磁盘上将可执行程序读入内存。
-
创建进程和主线程。
-
为主线程分配栈空间。
-
将用户在命令行输入的参数拷贝到主线程的栈。
-
将主线程放入操作系统的运行队列,等待被调度起来执行。
在主线程被第一次调度起来执行第一条指令之前,主线程函数栈的状态如下所示:
之后在Linux平台运行go build编译hello.go得到可执行hello文件,再使用gdb调试。
在gdb中首先使用info files找到程序入口(Entry point)地址为0x452270,然后使用b*0x452270在0x452270处下个断点,gdb就会显示出来入口对应的源码为文件runtime/rt0_linux_amd64.s第8行。
go$ go build hello.go
go$ gdb hello
GNU gdb (GDB) 8.0.1
(gdb) info files
Symbols from "go/main".
Local exec file:
`go/main', file type elf64-x86-64.
Entry point: 0x452270
0x0000000000401000 - 0x0000000000486aac is .text
0x0000000000487000 - 0x00000000004d1a73 is .rodata
0x00000000004d1c20 - 0x00000000004d27f0 is .typelink
0x00000000004d27f0 - 0x00000000004d2838 is .itablink
0x00000000004d2838 - 0x00000000004d2838 is .gosymtab
0x00000000004d2840 - 0x00000000005426d9 is .gopclntab
0x0000000000543000 - 0x000000000054fa9c is .noptrdata
0x000000000054faa0 - 0x0000000000556790 is .data
0x00000000005567a0 - 0x0000000000571ef0 is .bss
0x0000000000571f00 - 0x0000000000574658 is .noptrbss
0x0000000000400f9c - 0x0000000000401000 is .note.go.buildid
(gdb) b *0x452270
Breakpoint 1 at 0x452270: file /usr/local/go/src/runtime/rt0_linux_amd64.s, line 8.
使用代码编辑器找到runtime/rt0_linux_amd64.s文件,它是由Go汇编语言编写的源码文件,可参考《试着理解下Golang中的汇编语言》来阅读和理解以下代码,来看第8行:
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
JMP_rt0_amd64(SB)
上述代码第一行定义了_rt0_amd64_linux符号,并不是真正的CPU指令,下面的JMP才是主线程的第一条指令,这条指令意为跳到_rt0_amd64符号处继续执行(相当于Go/C的goto关键字)。
_rt0_amd64定义在runtime/asm_amd64.s文件第14行,代码如下:
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ0(SP), DI// argc
LEAQ8(SP), SI // argv
JMPruntime·rt0_go(SB)
前两行指令将操作系统内核传递过来的argc和argv两个参数地址分别放在DI和SI寄存器中,第三行指令意为跳到rt0_go符号处继续执行。
rt0_go完成了Go程序启动时所有初始化工作,此函数比较长,也很复杂,所以只看调度器相关的初始化。
先来看runtime/asm_amd64.s文件第87行定义的符号rt0_go:
TEXT runtime·rt0_go(SB),NOSPLIT,$0
// copy arguments forward on an even stack
MOVQDI, AX// AX = argc
MOVQSI, BX// BX = argv
SUBQ$(4*8+7), SP// 2args 2auto
ANDQ$~15, SP //调整栈顶寄存器使其按16字节对齐
MOVQAX, 16(SP) //argc放在SP + 16字节处
MOVQBX, 24(SP) //argv放在SP + 24字节处
上述代码第四条指令用于调整栈顶寄存器的值,使其按16字节对齐,也就是让栈顶寄存器SP指向内存地址为16的倍数,之所以要按照16字节对齐,是因为CPU有一组SSE指令,这些指令指向的内存地址必须是16的倍数,最后两条指令将argc和argv两个参数搬到新的位置。
之后就开始初始化全局变量g0,其主要作用就是提供一个栈供runtime执行,因此这里主要对g0的几个与栈相关的成员进行初始化,可以看到g0的栈约为64k大小,其地址范围为SP - 64*1024 + 104 ~ SP。
来看下runtime/asm_amd64.s文件第96行代码:
// create istack out of the given (operating system) stack.
// _cgo_init may update stackguard.
//下面这段代码从系统线程的栈空分出一部分当作g0的栈,然后初始化g0的栈信息和stackgard
MOVQ$runtime·g0(SB), DI //g0的地址放入DI寄存器
LEAQ(-64*1024+104)(SP), BX //BX = SP - 64*1024 + 104
MOVQBX, g_stackguard0(DI) //g0.stackguard0 = SP - 64*1024 + 104
MOVQBX, g_stackguard1(DI) //g0.stackguard1 = SP - 64*1024 + 104
MOVQBX, (g_stack+stack_lo)(DI) //g0.stack.lo = SP - 64*1024 + 104
MOVQSP, (g_stack+stack_hi)(DI) //g0.stack.hi = SP
运行上述指令后,g0与栈之间的关系如下所示:
设置g0栈后,跳过CPU型号检查及cgo初始化相关代码,看runtime/asm_amd64.s第164行代码:
//下面开始初始化tls(thread local storage,线程本地存储)
LEAQruntime·m0+m_tls(SB), DI //DI = &m0.tls,取m0的tls成员的地址到DI寄存器
CALLruntime·settls(SB) //调用settls设置线程本地存储,settls函数的参数在DI寄存器中
// store through it, to make sure it works
//验证settls是否可以正常工作,如果有问题则abort退出程序
get_tls(BX) //获取fs段基地址并放入BX寄存器,其实就是m0.tls[1]的地址,get_tls的代码由编译器生成
MOVQ$0x123, g(BX) //把整型常量0x123拷贝到fs段基地址偏移-8的内存位置,也就是m0.tls[0] = 0x123
MOVQruntime·m0+m_tls(SB), AX //AX = m0.tls[0]
CMPQAX, $0x123 //检查m0.tls[0]的值是否是通过线程本地存储存入的0x123来验证tls功能是否正常
JEQ 2(PC)
CALLruntime·abort(SB) //如果线程本地存储不能正常工作,退出程序
上述代码为了将m0与主线程关联起来,所以先调用settls初始化主线程的线程本地存储(TLS),后面几条指令则是为了验证TLS功能是否正常,异常则直接abort退出。
之后来看下settls是如何实现线程私有全局变量的。
runtime/sys_linx_amd64.s文件606行:
// set tls base to DI
TEXT runtime·settls(SB),NOSPLIT,$32
//......
//DI寄存器中存放的是m.tls[0]的地址,m的tls成员是一个数组,读者如果忘记了可以回头看一下m结构体的定义
//下面这一句代码把DI寄存器中的地址加8,为什么要+8呢,主要跟ELF可执行文件格式中的TLS实现的机制有关
//执行下面这句指令之后DI寄存器中的存放的就是m.tls[1]的地址了
ADDQ$8, DI// ELF wants to use -8(FS)
//下面通过arch_prctl系统调用设置FS段基址
MOVQDI, SI //SI存放arch_prctl系统调用的第二个参数
MOVQ$0x1002, DI// ARCH_SET_FS //arch_prctl的第一个参数
MOVQ$SYS_arch_prctl, AX //系统调用编号
SYSCALL
CMPQAX, $0xfffffffffffff001
JLS2(PC)
MOVL$0xf1, 0xf1 // crash //系统调用失败直接crash
RET
上述代码通过arch_prctl系统调用将m0.tls[1]的地址设置成了fs段的段基地址。
CPU有fs段寄存器与其对应,而每个线程都有自己的一组寄存器的值,操作系统在将线程调离CPU运行时会将寄存器中的值存储在内存中,调度线程起来运行时,又会从内存中将这些寄存器的值恢复到CPU,如此之后,工作线程就可通过fs来找到m.tls。
然后接着来看rt0_go,在runtime/asm_amd64.s第174行:
ok:
// set the per-goroutine and per-mach "registers"
get_tls(BX) //获取fs段基址到BX寄存器
LEAQruntime·g0(SB), CX //CX = g0的地址
MOVQCX, g(BX) //把g0的地址保存在线程本地存储里面,也就是m0.tls[0]=&g0
LEAQruntime·m0(SB), AX //AX = m0的地址
//把m0和g0关联起来m0->g0 = g0,g0->m = m0
// save m->g0 = g0
MOVQCX, m_g0(AX) //m0.g0 = g0
// save m0 to g0->m
MOVQAX, g_m(CX) //g0.m = m0
上述代码首先将g0的地址放在主线程的TLS,然后通过m0.g0 = &g0和g0.m = &m0这两个指令,将m0和g0绑定在一起,如此之后,在主线程中可通过get_tls获取到g0,通过g0的成员变量m又可以找到m0,于是就实现了m0与g0和主线程之间的关联。
还可看出保存在主线程TLS的值是g0地址,也就是说工作线程私有全局变量其实是个指向g的指针,而不是指向m,目前此指针指向g0,表示当前代码正运行在g0栈。
此时,m0,g0以及g0的栈之间的关系如下图所示:
之后的代码就开始处理命令行参数,处理完毕后调用osinit获取CPU核的数量,并将之保存在ncpu中,调度器初始化时需知道当前系统有多少个CPU内核。
再来看runtime/asm_amd64.s第189行:
//准备调用args函数,前面四条指令把参数放在栈上
MOVL16(SP), AX// AX = argc
MOVLAX, 0(SP) // argc放在栈顶
MOVQ24(SP), AX// AX = argv
MOVQAX, 8(SP) // argv放在SP + 8的位置
CALLruntime·args(SB) //处理操作系统传递过来的参数和env,不需要关心
//对于linx来说,osinit唯一功能就是获取CPU的核数并放在global变量ncpu中,
//调度器初始化时需要知道当前系统有多少CPU核
CALLruntime·osinit(SB) //执行的结果是全局变量 ncpu = CPU核数
CALLruntime·schedinit(SB) //调度系统初始化
还有runtime/proc.go文件第526行,来看调度器的初始化:
func schedinit() {
// raceinit must be the first call to race detector.
// In particular, it must be done before mallocinit below calls racemapshadow.
//getg函数在源代码中没有对应的定义,由编译器插入类似下面两行代码
//get_tls(CX)
//MOVQ g(CX), BX; BX存器里面现在放的是当前g结构体对象的地址
_g_ := getg() // _g_ = &g0
......
//设置最多启动10000个操作系统线程,也是最多10000个M
sched.maxmcount = 10000
......
mcommoninit(_g_.m) //初始化m0,因为从前面的代码我们知道g0->m = &m0
......
sched.lastpoll = uint64(nanotime())
procs := ncpu //系统中有多少核,就创建和初始化多少个p结构体对象
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n //如果环境变量指定了GOMAXPROCS,则创建指定数量的p
}
if procresize(procs) != nil {//创建和初始化全局变量allp
throw("unknown runnable goroutine during bootstrap")
}
......
}
通过执行前面的指令,g0的地址已经被设置到TLS,schedinit通过getg(编译器实现的函数,源代码中无定义)从TLS中获取当前正在运行的g,这里获取出来的是g0,然后调用mcommoninit对m0(g0.m)进行必要的初始化,对m0进行初始化之后调用procresize初始化系统需要用到的p结构体对象,按Go官方说法,p就是processor的意思,其数量决定了最多有少个goroutine可以同时并行运行。
schedinit除了初始化m0和p,还设置了全局变量sched的maxmcount成员为10000,限制最多可以创建10000个操作系统线程出来工作。
来关注下mcommoninit是如何创建m0以及procresize是如何初始化和创建p对象。
不仅初始化会执行mcommoninit,在系统运行过程中如果创建了工作线程也会执行它,所以才会在代码中看到加锁和检查线程数是否超过最大值等相关代码。
来看runtime/proc.go文件596行代码:
func mcommoninit(mp *m) {
_g_ := getg() //初始化过程中_g_ = g0
// g0 stack won't make sense for user (and is not necessary unwindable).
if _g_ != _g_.m.g0 { //函数调用栈traceback,不需要关心
callers(1, mp.createstack[:])
}
lock(&sched.lock)
if sched.mnext+1 < sched.mnext {
throw("runtime: thread ID overflow")
}
mp.id = sched.mnext
sched.mnext++
checkmcount() //检查已创建系统线程是否超过了数量限制(10000)
//random初始化
mp.fastrand[0] = 1597334677 * uint32(mp.id)
mp.fastrand[1] = uint32(cputicks())
if mp.fastrand[0]|mp.fastrand[1] == 0 {
mp.fastrand[1] = 1
}
//创建用于信号处理的gsignal,只是简单的从堆上分配一个g结构体对象,然后把栈设置好就返回了
mpreinit(mp)
if mp.gsignal != nil {
mp.gsignal.stackguard1 = mp.gsignal.stack.lo + _StackGuard
}
//把m挂入全局链表allm之中
// Add to allm so garbage collector doesn't free g->m
// when it is just in a register or thread-local storage.
mp.alllink = allm
// NumCgoCall() iterates over allm w/o schedlock,
// so we need to publish it safely.
atomicstorep(unsafe.Pointer(&allm), unsafe.Pointer(mp))
unlock(&sched.lock)
// Allocate memory to hold a cgo traceback if the cgo call crashes.
if iscgo || GOOS == "solaris" || GOOS == "windows" {
mp.cgoCallers = new(cgoCallers)
}
}
从上述代码中可以看到,此函数并未对m0做关于调度相关的初始化,所以可以认为这个函数只是将m0放入全局链表allm之中并返回。
m0初始化完成后,继续调用procresize创建和初始化p结构体对象,在此函数中会创建指定个数(根据CPU内核个数以及环境变量决定)的p结构体对象放在全局变量allp,并将m0与allp[0]绑定在一起,当此函数执行完毕之后就有m0.p = allp[0]和allp[0].m = &m0的关系,至此,m0,g0和m需要的p就完全关联在一起了。
再来看procresize函数。
考虑到初始化完成之后用户代码还可以通过GOMAXPROCS()调用procresize重新创建和初始化p结构体对象,然而在运行过程中再动态调整p牵涉到的问题较多,所以此函数的处理比较复杂,只看初始化的话,就比较简单了。
来看runtime/proc.go文件3093行:
func procresize(nprocs int32) *p {
old := gomaxprocs //系统初始化时 gomaxprocs = 0
......
// Grow allp if necessary.
if nprocs > int32(len(allp)) { //初始化时 len(allp) == 0
// Synchronize with retake, which could be running
// concurrently since it doesn't run on a P.
lock(&allpLock)
if nprocs <= int32(cap(allp)) {
allp = allp[:nprocs]
} else { //初始化时进入此分支,创建allp 切片
nallp := make([]*p, nprocs)
// Copy everything up to allp's cap so we
// never lose old allocated Ps.
copy(nallp, allp[:cap(allp)])
allp = nallp
}
unlock(&allpLock)
}
// initialize new P's
//循环创建nprocs个p并完成基本初始化
for i := int32(0); i < nprocs; i++ {
pp := allp[i]
if pp == nil {
pp = new(p)//调用内存分配器从堆上分配一个struct p
pp.id = i
pp.status = _Pgcstop
......
atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
}
......
}
......
_g_ := getg() // _g_ = g0
if _g_.m.p != 0 && _g_.m.p.ptr().id < nprocs {//初始化时m0->p还未初始化,所以不会执行这个分支
// continue to use the current P
_g_.m.p.ptr().status = _Prunning
_g_.m.p.ptr().mcache.prepareForSweep()
} else {//初始化时执行这个分支
// release the current P and acquire allp[0]
if _g_.m.p != 0 {//初始化时这里不执行
_g_.m.p.ptr().m = 0
}
_g_.m.p = 0
_g_.m.mcache = nil
p := allp[0]
p.m = 0
p.status = _Pidle
acquirep(p) //把p和m0关联起来,其实是这两个strct的成员相互赋值
if trace.enabled {
traceGoStart()
}
}
//下面这个for 循环把所有空闲的p放入空闲链表
var runnablePs *p
for i := nprocs - 1; i >= 0; i-- {
p := allp[i]
if _g_.m.p.ptr() == p {//allp[0]跟m0关联了,所以是不能放任
continue
}
p.status = _Pidle
if runqempty(p) {//初始化时除了allp[0]其它p全部执行这个分支,放入空闲链表
pidleput(p)
} else {
......
}
}
......
return runnablePs
}
此函数代码较长,但结构不是很复杂,汇总下此函数流程:
-
使用make([]*p, nprocs)初始化全局变量allp,即allp=make([]*p, nprocs)。
-
循环创建并初始化nprocs数量的p结构体对象并以此保存在allp切片中。
-
将m0与allp[0]绑定在一起,即m0.p = allp[0], allp[0].m = m0。
-
将除了allp[0]之外的所有全局变量放入sched的pidle空闲队列之中。
procresize执行完毕后,调度器相关的初始化工作基本结束了,这时整个调度器相关的各组成部分之间的关联关系如下图所示:
下篇文章来看Go第一个goroutine是如何创建的。
以上仅为个人观点,不一定准确,能帮到各位那是最好的。
好啦,到这里本文就结束了,喜欢的话就来个三连击吧。
扫码关注公众号,获取更多优质内容。