Python/Go开发

GO 语言的运行时初始化过程解析

2021-06-16  本文已影响0人  蟹蟹宁

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 栈空间

因为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)

二、GO runtime的初始化

2.1 极简概述

初始化的流程,我们仅仅考虑goroutime的部分(其实也是主要的核心,GC和内存初始化模块或者占比很小),主要流程是:

当然,整个流程没有这么的按部就班,接下来我们逐行分析初始流程

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结构。

g0的栈结构初始化结果如下,sp一开始在hi的位置,每次调用函数都sub sp,当达到stackguard0的数值,就会触发函数栈的扩容,这种栈的设计称之为连续栈

2.3.3 存储g0到TLS中


TLS是线程本地存储,在GO语言中,用于存放指向当前G的g结构体指针,也就是每次切换在M上执行的G,或者写换到M的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

核心代码:

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数组

代码的逻辑是很好理解,主要分为了两个部分:

3.4.3 释放多余的P

代码很长,但是这部分其实在初始化的时候是用不到的,因为所有的P都是刚刚创建的。其主要的内容可以概括为:

3.4.4 P0与M0绑定

因为该函数不一定是仅仅在初始化的过程中被调用,调用此函数的g所绑定的p可能被free掉了,这个时候也需要将g绑定到P0上,否则只需要修改其状态为_Prunning即可,绑定操作主要由函数acquirep()完成acquirep()函数

其实核心操作无非就是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的诞生

千呼万唤始出来,先看一下函数的声明:

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参数我们已经在最开始的初始化操作中设置了,当时的结构图是:


这里我们要给特定系统预留栈空间,以处理信号函数,重配置后的分布: 现在来看一下mstart1的内容:

5.2 保存g0的现场

gobuf的内容还是挺多的,但是对于g0而言,最重要的是sp指针,因此g0的存在就是为了执行管理类指令,实现和用户G栈空间的隔离,gobuf的结构体: 调用gosave的栈结构: image.png 我们看看gosave的代码:

经过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

我们先看一下,当执行到此处时的资源情况:

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进行绑定。

函数非常的简单:

七、M的生命周期

M和G、P一样都是复用的,一旦创建,就不会被销毁,M的状态转化是最简单,除了M0之外,都是通过newm()函数创建,然后通过mstart,进入调度循环,之后可以通过stopm进入阻塞状态,再通过startm唤醒,状态图如下:


图来自博客,很好的一个博客。mstart我们已经交代过了。
这篇博客写的很好,但是不是特别的详细,他有一段前言总结:

7.1 M startm()

stratm()函数是在调度M来执行p,如果p没有指定,那么就从idle_P列表中选择一个,如果没用空闲的P可
用,那就返回。

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的时候指定的。

附录

主要展开讲述一下小函数:

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,作用是设定对信号屏蔽集内的信号的处理方式(阻塞或不阻塞)。

cloneFlags: 重点来看一下clone: 系统调用的寄存器使用:
参数:DI、SI、DX、R10、R8、R9
返回值:AX、DX

1.2.1 调用clone系统调用

1.2.2 父进程部分

1.2.3 子进程部分

非常非常重要的是,我们在这里初始化了TLS的存储位置,也就是说每一个M都有自己的存放TLS的位置
这里的stackcheck就是检查当前的SP指针是否在[g->stack.lo, g->stack.hi)范围,如果不在将触发int 3中断
执行mstart函数(P绑定,然后进入调度循环)
卧槽读了这个汇编,突然发现一个大问题啊,这个返回父子进程的方法和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 获取后的处理

成功获取后需要对本地的链表以及计数进行更新。
如果栈被释放,那么还需要重新分配栈空间。

上一篇 下一篇

猜你喜欢

热点阅读