ps:这是我19年的写的总结,编辑成了pdf,当时用了很多截图,导致没法复制源码,原来注释的代码也找不到了,只能将就了。
一、汇编代码分析
本文将剖析,下面这段go语言代码运行的背后逻辑,主要从goroutine的调度层面,深度挖掘其背后的运
行逻辑。以go 1.5.1的源代码为分析:
1.1 go源代码
package main
func main(){
go add(1,2)
}
func add(a,b int)(int,int,int){
return a+b,a,b
}
先来看看上面这段程序的反汇编代码:
1.2 add函数反汇编代码
0x401050 48c744241800000000 MOVQ $0x0, 0x18(SP)
0x401059 48c744242000000000 MOVQ $0x0, 0x20(SP)
0x401062 48c744242800000000 MOVQ $0x0, 0x28(SP)
0x40106b 488b5c2408 MOVQ 0x8(SP), BX
0x401070 488b6c2410 MOVQ 0x10(SP), BP
0x401075 4801eb ADDQ BP, BX
0x401078 48895c2418 MOVQ BX, 0x18(SP)
0x40107d 488b5c2408 MOVQ 0x8(SP), BX
0x401082 48895c2420 MOVQ BX, 0x20(SP)
0x401087 488b5c2410 MOVQ 0x10(SP), BX
0x40108c 48895c2428 MOVQ BX, 0x28(SP)
0x401091 c3 RET
理解这段汇编只需要搞清楚add的栈空间即可:
因为add的栈帧大小是0,所以SP在CALL之后没有继续扩展,而且没有把BP压栈
1.3 main函数反汇编代码
0x401009 483b6110 CMPQ 0x10(CX), SP
0x40100d 7630 JBE 0x40103f
0x40100f 4883ec38 SUBQ $0x38, SP
0x401013 48c744241001000000 MOVQ $0x1, 0x10(SP)
0x40101c 48c744241802000000 MOVQ $0x2, 0x18(SP)
0x401025 c7042428000000 MOVL $0x28, 0(SP)
0x40102c 488d05257a0800 LEAQ 0x87a25(IP), AX
0x401033 4889442408 MOVQ AX, 0x8(SP)
0x401038 e8a3920200 CALL runtime.newproc(SB)
0x40103d ebfe JMP 0x40103d
0x40103f e80c9b0400 CALL runtime.morestack_noctxt(SB)
0x401044 ebba JMP main.main(SB)
- 第一句,是为了获取TLS的地址,可以认为是:MOVQ TLS,CX,在原指令中,我们相对FS基址寄存器向下偏移了8个字节,这是因为我们才将TLS的地址写入FS寄存器的时候是先加了8个字节,原因不知道,反正挺蠢的感觉,这样CX就指向了TLS
- TLS中存放了g结构体,0x10(CX),指向了g.stack.stackguard0,也就是当前g结构栈的警戒线,当SP指针小 于该值时就需要进行栈的扩展,也就是跳转到0x40103f ,执行 runtime.morestack_noctxt(SB)函数
-
此外,main函数的主要任务就是调用 runtime.newproc(SB)函数创建一个goroutime,此函数的参数包括 了:协程函数执行地址、参数返回值大小、参数及返回值共7个8字节的变量,因此SP扩展$0x38个字节, 栈空间:
runtime.newproc函数的具体功能暂且不展开,因为main.main本身也是一个goroutine,它显然不是go程序执行的入口,那么main goroutine是如何启动的?G、M、P是如何构建的,需要我们回到回到go世界的起点。
二、GO runtime的初始化
2.1 极简概述
初始化的流程,我们仅仅考虑goroutime的部分(其实也是主要的核心,GC和内存初始化模块或者占比很小),主要流程是:
- 创建M0
- 初始化P数组:allp
- M0与allp[0]绑定
- 创建G0,绑定M0,也就是main goroutine
- 启动M0的调度循环
当然,整个流程没有这么的按部就班,接下来我们逐行分析初始流程
2.2 入口地址 rt0_linux_amd64
通过dlv调试工具,我们很容易的知道,整个程序的入口地址:
_rt0_amd64_linux() /usr/local/go/src/runtime/rt0_linux_amd64.s:8
其核心的汇编代码如下:
所以入口函数的主要工作就是处理一下argc和argv两个参数,然后跳转到rt0_go完成整个初始化过程
2.3 初始化流程控制 rt0_go
其主要代码如下,我们只关注和goroutine相关的主要内容,一些乱七八糟的我们就不看了,主要我也没懂:
2.3.1 保存main参数到栈中
注意此时sp向下扩展了的48个字节,然后把argc和argv保存到了高位的两个28字节,我突然明白了其用意,因为在进行函数调用时,默认都是0(sp)作为第一个参数,这里预留了两个位置就是为了调用其他函数用的,当然前提保证了rt0_go调用的所有的函数的参数都不会超过2*8个字节
2.3.2 初始化g0的栈结构
g0的身份已经明确了,每一个M都自带一个g0结构,用于执行管理类的指令,从而将其和M执行的用户goroutine区分开,这里的g0就是我们的初始化的M0的g0结构。
2.3.3 存储g0到TLS中
TLS是线程本地存储,在GO语言中,用于存放指向当前G的g结构体指针,也就是每次切换在M上执行的G,或者写换到M的g0结构的时候都要进行TLS的切换。
上面的过程进行了部分的操作:
- 第一部分通过系统调用arch_prctl,把tls0这个全局变量的地址写到了FS寄存器,TLS正是通过FS基质寄存器进行殉职的,tls0指向的是一个8*8字节大小的指针数组
- 第二部分是一个测试代码,整明tls0和TLS之间的指向关系
- 第三部分吧g0的地址写入到TLS中
整个过程的内存操作细节如下:
tls0是一个8*8字节的空间
通过arch_prctl,让FS基址寄存器指向该空间
settls函数的源代码中,赋值给FS的就是 tls0+8,我们可以查看main.main的反汇编代码的第一句:
0x401000 64488b0c25f8ffffff FS MOVQ FS:0xfffffff8, CX
这里在进行段寻址的时候时候,偏移量是0xfffffff8,也就是-8,刚好指向了tls0的首地址。
因此 TLS就指向了正确的地址
存储g0
get_tls(r) 和 g()都是宏函数:
#define get_tls(r) MOVQ TLS, r
#define g(r) 0(r)(TLS*1)
两者可以用一个指令代替:
MOVQ (TLS), r
因此存储g0也可以简写为:
MOVQ (TLS),BX
MOVQ $runtime.g0,BX
注意,g0本身也是一个指针
2.3.4 构建M0和g0的关系
这里的m0是一个全局变量,也是系统创建的第一个M,用于执行main goroutine,这里将M0和g0相互绑定,当然M0还有很多的内容需要初始化
2.3.5 初始化main参数
参数入栈,然后通过runtime.args()函数,保存到全局变量的runtime.agrc、runtime.argv中,之后就可以在go中引用了
2.3.6 确定系统的内核数目
2.3.7 初始化P,实现M0与P的绑定
这个部分我们放到第三部分展开
2.3.8 创建Main goroutine,并将其绑定到P的可运行队列中
又一次见到runtime.newproc函数,这是该函数第一次被调用,用于创建main goroutine,其中协程的函数地址是runtime.mainPC,这是一个全局变量,定义如下:
runtime.main距离main.main还有一点距离,我们也将在第四部分展开
2.3.9 最后,启动M0,开启M0的执行调度循环
mstart() 函数,在第五部分展开。
三、 schedinit() 过程
这里对应于2.3.7小节,初始化P,实现M0与P的绑定
3.1 其他内容
这部分与goroutine的建立没有直接关系,我们暂不展开,看一下注释即可3.2 初始化M0
到目前为止,M0还是一个全局的变量,仅仅和g0进行了相互绑定,还没有被纳入到M的管理框架中,将使用mcommoninit(g.m)初始化M0
- 每一个M都有自己唯一的ID,每一个M只要被创建就永远不会被销毁,M是可以复用的,并且M是有最大创建个数的,默认是10000个,因此代码的前三行就是为M0分配ID,然后检查是否已经超过最大的M创建限制
- mpreinit(mp)是个空操作
- 将M0添加到allm中,allm是一个链表头,指向了新加入的链表项,每次都在链表的尾部追加新的M
3.3 确定P的个数
P的个数,最终是由系统的核数、环境变量GOMAXPROCS、自定义最大值_MaxGomaxprocs(256),三者共同确定
3.4 初始化P
func procresize(nprocs int32) *p
从函数名可以看出,该函数适用于调整P的个数的。P的个数是拥有最大值的,也就是_MaxGomaxprocs(256),因此为了节省开销,P是由数组进行管理的,allp是一个全局数组,用于存放P,gomaxprocs用于存放有效的P个数。
3.4.1 获取原先的P个数
在初始化的过程中,其实是创建P的过程,因此old的值是0
3.4.2 根据nprocs初始化P数组
代码的逻辑是很好理解,主要分为了两个部分:
-
初始化P,指定它的id、status等xinxi
● 为p分配cache,这部分内容属于内存分配,暂时不展开,可以理解为cache是goroutine申请内容用的,这里面需要注意的是,针对初始化过程,P0的cache是由M0提供的,而M0的cache其实是在进行内存分配初始化(schedinit.mallocinit)的时候指定的:
3.4.3 释放多余的P
代码很长,但是这部分其实在初始化的时候是用不到的,因为所有的P都是刚刚创建的。其主要的内容可以概括为:
- 将P上的所有的G转移到全局队列
- 释放P的cache
- 将P的复用G链表转移到全局队列
- 修改其状态为_Pdead
3.4.4 P0与M0绑定
其实核心操作无非就是cache和m与p之间相互指向,最后修改P的状态为_Prunning
3.4.5 最后,没有本地任务的P放入空闲链表
上述代码的逻辑是很容易看懂的,执行结束后,除了P0以外,所有的P都放入了空闲列表,当然P也没有放入有任务的列表,毕竟当前P是没有任务的,正在执行的g是M0的g0,因此接下来就是创建我们的G0,也就是maingoroutine了。
四、创建Main Goroutine
这里对应于2.3.8小节,创建Main goroutine,并将其绑定到P的可运行队列中。
现在我们来看看newproc函数都做了什么工作,在此之前,我们先可视化一下函数栈
4.1 整理参数,切换执行栈到g0
这个函数其实没什么好讲的,agrp其实是协程的第一个参数,因为根据压栈的规则,协程的函数地址再往上就是协程函数的参数和返回值,但是在这个函数中是没有调用任何的参数的,因为size是0。PC获取的是返回IP也就是栈中的callerIP,将&siz减8就是了。
最后通过systemsatck函数来执行newproc1,前者的作用是切换到g0中执行,执行完成后恢复到原有的g中,当然了,此时已经在g0的栈中执行了,所以这个场景下这个函数是没有作用的,我们将在后面附录中分析这个g0栈的切换函数。
4.2 Main Goroutine的诞生
千呼万唤始出来,先看一下函数的声明:- funcval是协程函数的入口地址
- argp是协程函数的参数地址
- narg表示协程函数参数的大小
- nret表示协程函数返回值的大小,但是这个参数是没有意义的,因为参数包含了返回值
- caller PC可以理解为go func的地址,即启动该协程的函数地址
4.2.1 创建或者复用一个G
如果有可以复用的G,那么就选择复用,否则就创建一个新的G,因为我们是初始化的过程,因此一定是新建一个G,具
体的细节我们放在后面讨论
4.2.2 参数COPY
很容易理解,就是把协程函数的参数从go的栈空间copy到G的栈空间
4.2.3 初始化gobuf,用于goroutine切换
gobuf是一个重要的数据结构,对应的变量是newg.sched,保存了重要的寄存器信息,当发生goroutine切换的时候,将会用到,这里需要注意,newg.sched.pc,这里载入的值是goexit,这是一个goroutine在运行结束后通过ret跳转到的函数地址,用于善后工作,我们将在调度循环中描述
4.2.4 分配全局的ID
4.2.5 将G加入p的运行队列
4.2.6 让空闲的P出来工作
到这里对于普通的goroutine来说已经结束了,只需要等待被调度到就可以了,但是对于Main goroutine还没有结束,因为M的调度循环还没有开启
五、启动M0,开启调度循环
这是runtime的最后一步,开启循环,执行goroutine.
5.1 重新初始化栈guard
首先注意,这里的g依然是M0的g0,而不是main goroutine。
g0栈的guard参数我们已经在最开始的初始化操作中设置了,当时的结构图是:
5.2 保存g0的现场
- 首先把gobuf的指针赋值给AX,然后可以通过AX访问gobuf
- 接下来把caller的SP指针保存到gobuf中,里是比较有趣的,从栈的信息看,这里保存的是gosave函数
RET之后的SP指针,十分的合理,因为伪寄存器FP指向的就是参数的地址,arg0的地址就是caller的SP
指针 - 将callerPC保存到PC中,这一步显然是没有必要的,因为我们根本就用不到,用户的G的gobuf.pc保存
的是go_exit的地址 - 后面的就是保存相应的数据了,没啥好说的
经过gosave,我们就把M0的g0的gobuf给初始化好了
5.3 设置信号处理
当时的我对信号机制不熟悉,所以没看懂,现在的我应该可以吧,233,但是懒得看了
5.4其他
这部分的内容在初始化的过程中并不会被执行,先不深究
5.4开启调度循环
这一句执行后,我们的M将开始执行,将从其绑定的P上取任务开始执行调度的循环,由于此时只有maingoroutine,因此我们的main函数终于可以执行了,但是前面我们已经提到过了,mian goroutine的入口函数是runtime.main,而不是main.main,因此我们还有一段路程要走,至于调度循环,我们将单独拿出来讨论。
六、Main Goroutine的执行
runtime.main() /usr/local/go/src/runtime/proc.go:28我们先看一下,当执行到此处时的资源情况:
- 首先,堆栈的追踪,当前执行的是runtime.main,其调用函数是goexit,其实这部分呢我们在前面已经说过了,所有的goroutine的调用函数都是goexit,这样当其执行结束时,可以返回到goexit中,继续调度循环,goexit的地址保存在gobuf.pc中
- 此时只有一个OS线程,也就是初始化线程本身,其实我们知道,每个M都是个一个OS线程绑定的,那么我们的m0默认绑定的线程就是初始化线程本身,而所谓M和os线程的绑定,说白了就是栈空间,当OS线程的栈地址是M的g0的栈地址,那么两者就绑定了,回忆一下,g0的栈是怎么初始化的,就是在当前OS线程的栈空间中划出了64KB的大小
- 此时只有一个goroutine,也就是我们的main goroutine
6.1 启动系统后台监控
这里首先使用systemstack函数切换到m0的g0栈空间来执行命令
newm是创建一个新的M,并指定了启动函数(sysmon),sysmon的详细内容我们就不研究了,但是newm我们将在后面展开讨论,注意newm的第一个参数,在启动M0这一节中的“其他”小节的代码上中,执行的fn就是这个时候传递进去的。
6.2 执行runtime的init函数
此函数是一个由编译器自动生成的函数,其目的是执行runtime这个package下的所有的init()函数
6.3 执行其他的init函数
同runtime_init一样,也是一个由编译器生成的函数,其目的是执行除了runtime之外,其他所有的被引用的package的init()函数
6.4最后执行main.main
时间线收束,到现在为止,我们已经把main函数的执行过程全部走了一边,除了一些函数的细节没有展开,我们将放到附录中。
六、回忆goroutine的的创建
还记得我们的add goroutine吗?和main goroutine一样,都是调用了newproc(4.2)函数,而该函数的结尾是这里(4.2.6):如注释所写,如果当前存在空闲的P,那么将会唤醒一个M来干活,如果此时创建的是main goroutine,那么不需要执行,如果当前有自旋等待的M,那么也不需要执行,很显然,在我们创建add goroutine时,显然存在空闲的P,因为只有allp[0]在执行,显然也没有自选等待的M,那么将会执行wakep()操作,wakep()做了什么呢?
wakep()
此函数的初心是,尝试启动一个P来执行G,源码的注释就是启动一个P,而不是启动一个M,当然了,启动M需要和P进行绑定。函数非常的简单:
- 检查当前是否存在处于spinning状态的M,如果存在,那么放弃执行,以避免创建过多的M,如果不存在,则spinning状态的M计数加1
- 执行stratm(),来唤醒一个M,具体看下,七
七、M的生命周期
M和G、P一样都是复用的,一旦创建,就不会被销毁,M的状态转化是最简单,除了M0之外,都是通过newm()函数创建,然后通过mstart,进入调度循环,之后可以通过stopm进入阻塞状态,再通过startm唤醒,状态图如下:
图来自博客,很好的一个博客。mstart我们已经交代过了。
这篇博客写的很好,但是不是特别的详细,他有一段前言总结:
- mstart,Go程序初始化时,第一个M是由mstart创建,新的物理线程创建时,调用的函数也是mstart。
- startm,当有新的G创建或者有G从waiting进入running且还有空闲的P,此时会调用startm,获取一个M和空闲的P绑定执行G。
- newm,当调用startm时,如果没有空闲的M则会通过newm创建M。
- stopm,在2种情况下会执行stopm,一是当M绑定的P无可运行的G且无法从其它P窃取可运行的G时,M先进入spinning状态,然后退出。二是当M和G进入系统调用后,长时间未退出,P被retake且M找不到空闲的P绑定,此时M会调用stopm。
- spinning状态,在findrunnable函数中,会短暂进入spinning状态,如果找不到可运行的G则调用stopm。
这里最迷惑的就是这个spinning,其实他并不是一种M的状态,博客中说的非常好,从M的状态其实是对应于OS线程的状态,通过线程的同步机制futex来实现了线程的唤醒和阻塞,分别对应于图中的startm和stopm,stopm类似于一种暂停,唤醒之后还是要从停下的位置继续执行的,这里在findrunnable的代码中就可以看出来,stopm后面是goto语句,回到函数的的最开始,重新寻找可用的G。我们可以把running状态分为两种情况: - 执行用户的程序
- 寻找可用的G
其中寻找可用的G的过程就是spinning状态:
spinning状态 = running状态中执行findrunnable的部分
7.1 M startm()
用,那就返回。
7.1.1获取空闲的P
调用函数pidleget()获取空闲列别中的P,如果获取失败,那么M的自旋计数-1(对应于wakep中的自旋+1),然后返回。
7.1.2 获取复用M或创建新M
如果需要新新创建M,那么就调用newm,新创建M同时意味着产生新的os线程,新线程将调用mstart,启动自己的调度循环。
7.1.3 绑定P,唤醒阻塞的M
M在执行的时候一定要和P进行绑定,因此我们在这里这只M被唤醒之后的P。
这里的notewakep就是利用了Linux的Futex技术。
7.2 M stopm()
7.2.1 取消自旋计数
7.2.2 M放入空闲队列并阻塞
在执行notewakep后,代码将从noteclear开始执行,clear完全是为了和win兼容,其实就是把note值清零
7.2.3 M被唤醒后的初始化工作
因为被唤醒后的代码是从noteclear开始,因此这部分应该是startm的后续,主要是处理GC和绑定P,P就是我们在stratm的时候指定的。
附录
主要展开讲述一下小函数:
- newm() 生成新的M
- allocm() 初始化M对象
- newosproc() 申请OS线程并与M绑定
- malg 创建一个G
- systemstack 切换到M的g0栈空间
- gfget() 从G的复用列表中获取一个G
1. newm
1.1 创建和初始化M对象
1.1.1 new一个M对象然后初始化
mcommoninit(3.2)函数已经介绍过了
1.1.2 创建g0
创建G的函数malg在下面马上讲,参数是栈的大小。
这里默认g0的栈大小是8KB(stackGuardMutiplier = 1),但是如果gcflags设置了-N标志(不启动编译优化),那么stackGuardMutiplier的值就会变成2.
1.1.3 暂存P对象
此时M和P没有绑定,绑定是在mstart(五)函数的中的其他(5.4)中进行的
1.2 分配OS线程
rtsigprocmask对应于系统调用rt_sigprocmask,作用是设定对信号屏蔽集内的信号的处理方式(阻塞或不阻塞)。
参数:DI、SI、DX、R10、R8、R9
返回值:AX、DX
1.2.1 调用clone系统调用
1.2.2 父进程部分
1.2.3 子进程部分
这里的stackcheck就是检查当前的SP指针是否在[g->stack.lo, g->stack.hi)范围,如果不在将触发int 3中断
卧槽读了这个汇编,突然发现一个大问题啊,这个返回父子进程的方法和fork很像,但是传递一个void*函数的方法尤其确实我们的clone,嘶~~~,clone的实现,不会就是这样的吧,不管了。
2. malg
stackalloc是内存分配相关的,就不看了
3.systemstack
3.1 判断是否需要切换
这部分没什么好说的如果已经是g0或者是gsignal(不知道是啥)就不需要切换
3.2 保存G的现场
3.3 切换到g0
这里主要就是把g0写到TLS中,然后修改SP指针,从这里可以看出,并没有参数的copy操作,也就是此方法是不支持运行带有参数的函数。
这里面比较诡异的是吧runtime.mstart入栈,看不懂
3.4 调用原函数
注意这里不是CALL DI
3.5 恢复G现场
4. gfget
每一个P包含一个本地的可复用G链表:p.gfree及其计数p.gfreecnt;
系统中还有一个全局的可复用G链表:sched.gfree及其计数p.ngfree.
在gfput时,当p.gfree中的可复用G数目超过64时(是的,代码中直接用了常数64,甚至没有定义常量),会将一半数目的也就是32个p.gfreeG放入sched.gfree;
在gfget时,若p.gfree中没有可用的G,那么将会从sched.gfree中取走32个放入本地。
4.1 获取和窃取
上述代码实现了窃取操作,如果本地没有,那么就从全局可复用链表中窃取32个,注意链表操作。
4.2 获取后的处理
成功获取后需要对本地的链表以及计数进行更新。
如果栈被释放,那么还需要重新分配栈空间。