28 December 2019

目录

1.源代码获取

截止目前2019-12-28为止,最新版本为:1.13.5

直接从Golang官方网站下载:https://dl.google.com/go/go1.13.5.src.tar.gz

2. 新版本和早期版本的实现差异

  • runtime改用golang+plan 9汇编实现,早期版本使用C+plan 9汇编
  • 新版本支持动态库,因此runtime有一些改变,早期版本只支持静态链接

3. Go程序如何启动

启动点代码在src/runtime/rt0_xxx_xxx.s中,用plan 9汇编编写,按照go的规范,按照rt0_{OS}_{Arch}.s的形式命名源代码。

  • rt0表示源代码名称
  • {OS} 表示的是操作系统名,可取值:linux/android/darwin/dragonfly/freebsd/illumos/nacl/netbsd/openbsd/pan9/solaris/windows,代表了go所支持的操作系统。
  • {Arch} 表示体系结构,可取值:386/amd64/arm/arm64/mips64x/mipsx/ppc64/ppc64le,表示go所支持的体系结构。

例如:rt0_linux_amd64.s 表示 运行于linux操作系统下,amd64(x86-64) CPU下的启动代码。

以下内容以linux操作系统,amd64为例进行说明

3.1 rt0 过程

src/runtime/rt0_linux_amd64.s 并无实际内容,只有两个函数:_rt0_amd64_linux, _rt0_amd64_linux_lib,这两个函数中只有一条指令,就是分别跳转到_rt0_amd64/_rt0_amd64_lib,似乎暗示:在所有amd64体系结构中,启动点都是_rt0_amd64/_rt0_amd64_lib,与操作系统无关。查看了其他操作系统版本的实现,发现确实如此,大部分OS在amd64体系结构中,都是跳转到同样的函数,部分OS有一些特殊处理。

_rt0_amd64/_rt0_amd64_lib 定义在src/runtime/asm_amd64.s中:

  1. 先看_rt0_amd64: 从注释得知,当用内部链接(internal linking)时这是大部分amd64系统的常规启动点,使用-buildmode=exe参数的程序会使用到这个启动点。堆栈遵从C风格的argv;它只做了很简单的事:
    • 将rsp+0移动到rdi(C ABI的第1个操作数)
    • 取rsp+8的有效地址到rsi(C ABI的第2个操作数)
    • 跳转到runtime·rt0_go函数
    • 上面的操作相当于设置argc、argv到第1,第2个参数,然后跳转到runtime·rt0_go函数
  2. main: 当使用外部链接(external linking)时,这也是大部分amd64系统的常规启动点;C启动代码将会调用符号:”main”(就是本函数),并传递argc、argv作为参数(按照C abi,因该是rdi, rsi这个两个寄存器里)。rt0_linux_amd64.s,并没有引用这个函数,因为它是C启动代码直接通过调用符号”main”调用的。这个函数没有任何操作,只是简单的跳转到runtime·rt0_go函数
  3. _rt0_amd64_lib: _rt0_amd64main都是作为独立程序的启动点,_rt0_amd64_lib不同于它们,它是作为静态/动态库的启动点。当使用-buildmode=c-archive或者-buildmode=c-shared参数时候,链接器将会把这个函数作为构造器,即第一次加载时调用,也会传递遵从C ABI传递argc、argv两个参数。
    • _rt0_amd64_lib会按C ABI要求,保护并恢复以下寄存器:rbx,r12,r13,r14,r15
    • 调用runtime·libpreinit进行同步初始化
    • 如果支持cgo:调用_cgo_sys_thread_create(_rt0_amd64_lib_go, NULL)创建一个新线程完成go runtime初始化;即在新线程中调用_rt0_amd64_lib_go,_rt0_amd64_lib_go其实就是设置argc、argv然后调用runtime·rt0_go
    • 如果不支持cgo:调用runtime·newosproc0(0x800000, _rt0_amd64_lib_go),注意: runtime·newosproc0是一个go函数,调用方式不遵从C ABI,而使用go ABI,完全使用栈传递参数。这个函数先分配 0x800000(8MB)的栈空间,然后使用clone系统调用fork一个新线程,然后在新线程中执行runtime·rt0_go。
    • 可见:最终执行过程一定是启动一个新线程,异步执行runtime·rt0_go

以上三个启动点,最后一定会调用runtime·rt0_go,可见runtime·rt0_go才是rt0的最终阶段。

runtime·rt0_go 定义在src/runtime/asm_amd64.s中,它负责真正的go rt0启动过程:

  1. argc、argv在已经按C ABI要求放入rdi、rsi,这是要按go ABI的要求,放入栈中:具体被放置在rsp+16, rsp+24
  2. 初始化g0 stackguard:这一步引用了一个定义在src/runtime/proc.go的全局变量g0, g0的类型为g,表示的是golang中的goroutine,g0就是主goroutine,即执行main.main函数的goroutine;获取rsp-(64*1024+104)处的有效地址(leaq指令),并放到g0.stackguard0, g0.stackguard1中,然后将栈顶、栈底指针分别放置到g0.stack.lo和g0.stack.hi中。
  3. 使用CPUID指令测试CPU的能力。
  4. 在_cgo_init之前再更新g0的stackguard:重新将g0.stackguard0和g0.stackguard1更新为g0.stack.lo+ StackGuard;StackGuard的作用是检查栈溢出,然后panic。StackGuard区域的大小和操作系统体系结构相关,每个系统不一定一致。
  5. 初始化TLS:这一步又引用了一个定义在src/runtime/proc.go的全局变量m0,m0的 类型为m(意为:machine),每一个m对应一个OS线程,m0对应主线程;本步骤就是简单的调用runtime·settls(&m0.tls),settls就是简单的调用系统调用arch_prctl,实际的效果就是: arch_prctl(ARCH_SET_FS, &m0.tls + 8) (m0.tls是个数组),arch_prctl是个amd64体系结构特有的方法,它的作用是将一个64bit的基地址设置到fs(Extra Segment Register:附加段寄存器)寄存器,这个系统调用主要用于配合实现TLS;然后调用一次get_tls()确保TLS初始化成功。将g0的地址设置到tls中,然后设置:m0.g0 = &g0 g0.m = &m0
  6. 基础检查:调用src/runtime/runtime1.go中的check()函数,进行一些基础检查,一旦失败,马上打印错误信息并自我崩溃。检查的内容相当基础:各个数据类型的大小、原子操作的正确性、时间函数的正确性、取地址操作(&)的正确性、栈大小是否能取整为2的幂、汇编器的正确性。这个检查是为了防止编译器bug,导致编译出不正确的程序。
  7. 初始化调度器:调用runtime·args(argc, argv)初始化argv,使得应用可以通过os.Args获取到运行参数,然后调用runtime·osinit()进行操作系统初始化,这个方法相当简单,仅仅获取cpu个数和物理内存页大小。最后调用的runtime·schedinit()才开始真正进行调度器初始化:
    1. tracebackinit() 初始化变量
    2. moduledataverify() 检查模块符号表
    3. stackinit() 初始化栈池(stackpool)
    4. mallocinit()初始化内存分配器(TCMalloc)
    5. mcommoninit(g.m)初始化m
    6. cpuinit() 设置CPU的特性
    7. alginit() 初始化AES算法,如果CPU支持硬件AES,将会利用CPU指令加速AES算法
    8. modulesinit() 模块初始化,将所有已加载的模块放入全局变量modulesSlice中
    9. typelinksinit() 从所有已加载的模块中扫描类型信息
    10. itabsinit()
    11. msigsave(g.m) 保存当前m的信号量,即当前OS线程的信号量
    12. goargs() 将argv拷贝到全局变量argslice中
    13. goenvs() 将环境变量拷贝到全局变量envs中
    14. parsedebugvars() 处理调试参数,调试参数从环境变量GODEBUG获取(正好利用到了全局变量envs)
    15. gcinit() 初始化垃圾回收器
    16. 设置OS线程数,如果设置了环境变量GOMAXPROCS,就使用这个值,否则设置为cpu核数;调用procresize重新设置线程数(processors)
    17. 最后设置一些全局变量,完成初始化
  8. 调度器初始化完成之后,可以使用goroutine了,此时使用runtime·newproc(runtime·main)新建一个goroutine,调用runtime·main函数
    1. 实际的代码在newproc1函数中,runtime·newproc只是使用systemstack函数切换到系统栈(g0的栈)调用newproc1
    2. 先关闭抢占acquirem(),因为会持有p的局部变量
    3. 调用gfget(__p__) 尝试从free-list中获取一个新的g;这是一个优化,每个p有本地的free-list(p.gFree)缓存释放的g,下次需要分配g的时候,可以快速获取,如果本地的free-list没有g了,会从全局的free-list获取(sched.gFree)
    4. 如果获取不到g,就从新分配一个(初始化阶段,应该是获取不到的,因为g没有释放过),调用malg实际分配一个g,并分配足够的栈空间。然后将g的状态从_Gidle切换到_Gdead,再把新g加入allg全局数组中。
    5. 处理参数,栈对栈的拷贝go入口函数的参数。
    6. 初始化g的一些成员变量,最重要是是设置入口函数: newg.startpc = fn.fn这里等效于newg.startpc = runtime·main,以及调用者的PC(程序计数器,即调用者的具体调用位置): newg.gopc = callerpc
    7. 成员变量初始化完成后,设置将g的状态从_Gdead切换到_Grunnable
    8. 设置g的goid
    9. 调用runqput,尝试将新创建的g放入本地可执行队列(local runable queue),此时新创建的g已经加入等待队列,等待被调度器调度执行。
    10. 如果空闲的p不为0,自旋的m为0,且main goroutine已经启动,则调用weakp()尝试增加一个或者多个p来执行g。
    11. 最后开抢占releasem(_g_.m)
  9. 最后调用runtime·mstart启动m,rt0阶段就结束了,程序此时应该可以正常运行了。
    1. 设置当前g的stackguard之后,调用mstart1()
    2. mstart1()先记录下调用者的PC和SP(栈指针),然后调用minit(): 设置信号量处理函数及其栈空间,并设置m.procid为当前线程ID。
    3. 如果当前m是m0,调用mstartm0(),初始化阶段,主线程就是m0,应该一定会调用mstartm0(): 这个函数主要调用了initsig(false)初始化信号量,各个OS实现有所不同,他将信号处理函数设置为go函数
    4. 最后调用schedule(),开始调度处理,这时rt0阶段完全结束,开始真正执行go程序!
    5. go程序执行完之后,mstart会调用mexit(osStack): 最终调用了syscall的exit结束线程/进程

第8步中,使用runtime·newproc(runtime·main)创建新goroutine,这个main不是应用程序的main·main()入口函数,而是runtime·main,定义在src/runtime/proc.go,这个函数会在其中调用main_main()main·main(),应用程序的main函数:

  1. 先根据32bit/64bit系统不同情况,设置最大栈大小到maxstacksize全局变量中,64bit系统大约为953MB。
  2. 设置一个重要的全局变量mainStarted = true,表示runtime·main已经执行过了。
  3. 创建一个新的m(OS线程),并将m的入口函数设置为sysmon
  4. 此时才开始执行main_main(),进入应用main函数
  5. 应用main函数返回之后,还要检查有没有处理panic的defer,如果有就会反复调用runtime.Gosched()直到defer最终处理完。还要检查是否有未unrecover的panic,如果有就会调用gopark等待panic最终处理完。
  6. 最后调用exit(0)退出进程,runtime·main正常情况下永远不会返回的。

其中procresize会初始化p(processor),初始化时,因为是第一次运行procresize,他会给全局变量allp(存储所有p的全局数组)分配空间,然后调用func (pp *p) init(id int32)初始化每一个p。

func getg() *g: 编译器会重写这个函数,将其替换成指令

func systemstack(fn func()): 使用系统栈调用函数fn. 如果从操作系统线程(per-OS-thread)g0栈调用这个函数或者从信号处理函数(gsignal)栈调用这个函数,那么只会简单的调用fn并返回,没有特殊处理。 除此之外,比如从goroutine大小受限的栈调用本函数,那么他会切换到操作系统线程(per-OS-thread g0)栈调用fn,然后再切换到原来的栈。 这个函数的意义在于:处理一些特殊的,可能占用较多栈空间的调用,如果直接在gorouine的栈发起调用,可能会导致栈溢出。

3.2 rt0 过程总结

desktop



blog comments powered by Disqus