goroutine 调度器初始化

我们以 Hello World 程序为例,通过跟踪其从启动到退出这一完整的运行流程来分析 Go 语言调度器的初始化、goroutine的创建与退出、工作线程的调度循环以及 goroutine 的切换等重要内容。

package main
import "fmt"

func main() {
    fmt.Println("Hello World!")
}

首先我们从程序启动开始分析调度器的初始化。 在分析程序的启动过程之前,我们首先来看看程序在执行第一条指令之前其栈的初始状态。

任何一个由编译型语言(不管是C,C++,go还是汇编语言)所编写的程序在被操作系统加载起来运行时都会顺序经过如下几个阶段:

  • 从磁盘上把可执行程序读入内存;
  • 创建进程和主线程; 为主线程分配栈空间;
  • 把由用户在命令行输入的参数拷贝到主线程的栈;
  • 把主线程放入操作系统的运行队列等待被调度执起来运行。

在主线程第一次被调度起来执行第一条指令之前,主线程的函数栈如下图所示:

 

1. 程序入口

在Linux命令行用 go build 编译 hello.go,得到可执行程序 hello,编译命令如下:

go build -ldflags="-compressdwarf=false" hello.go

我们使用 gdb 调试,在 gdb 中我们首先使用 info files 命令找到程序入口(Entry point)地址为0x452270,然后用 b *0x452270 在0x452270地址处下个断点,gdb告诉我们这个入口对应的源代码为 runtime/rt0_linux_amd64.s 文件的第8行。

$ go build -ldflags="-compressdwarf=false" hello.go
$ gdb hello
GNU gdb (GDB) 8.0.1
(gdb) info files
Symbols from "/data/go/main".
Local exec file:
`/data/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 1at 0x452270: file /usr/local/go/src/runtime/rt0_linux_amd64.s, line 8.

打开代码编辑器,找到 runtime/rt0_linx_amd64.s 文件,该文件是用go汇编语言编写而成的源代码文件,我们已经在本书的第一部分讨论过其格式。现在看看第8行:

TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
    JMP _rt0_amd64(SB)

上面第一行代码定义了_rt0_amd64_linux这个符号,并不是真正的CPU指令,第二行的JMP指令才是主线程的第一条指令,这条指令简单的跳转到(相当于go语言或c中的goto)_rt0_amd64 这个符号处继续执行,_rt0_amd64 这个符号的定义在runtime/asm_amd64.s 文件中:

TEXT _rt0_amd64(SB),NOSPLIT,$-8
    MOVQ 0(SP), DI // argc 
    LEAQ 8(SP), SI // argv
    JMP runtime·rt0_go(SB)

前两行指令把操作系统内核传递过来的参数argc和argv数组的地址分别放在DI和SI寄存器中,第三行指令跳转到 rt0_go 去执行。 rt0_go函数完成了go程序启动时的所有初始化工作,因此这个函数比较长,也比较繁杂,但这里我们只关注与调度器相关的一些初始化,下面我们分段来看:

TEXT runtime·rt0_go(SB),NOSPLIT,$0
    // copy arguments forward on an even stack
    MOVQ DI, AX // AX=argc
    MOVQ SI, BX // BX=argv
    SUBQ $(4*8+7), SP // 2args 2auto
    ANDQ $~15, SP    //调整栈顶寄存器使其按16字节对齐
    MOVQ AX, 16(SP) //argc放在SP+ 16字节处
    MOVQ BX, 24(SP) //argv放在SP+ 24字节处

上面的第4条指令用于调整栈顶寄存器的值使其按16字节对齐,也就是让栈顶寄存器SP指向的内存的地址为16的倍数,之所以要按16字节对齐,是因为CPU有一组SSE指令,这些指令中出现的内存地址必须是16的倍数,最后两条指令把argc和argv搬到新的位置。这段代码的其它部分已经做了比较详细的注释,所以这里就不做过多的解释了。

 

2. 初始化g0

继续看后面的代码,下面开始初始化全局变量g0,前面我们说过,g0的主要作用是提供一个栈供runtime代码执行,因此这里主要对g0的几个与栈有关的成员进行了初始化,从这里可以看出g0的栈大约有64K,地址范围为 SP - 64*1024 + 104 ~ SP。

一、布局图 二、说明 名称存储内容stack局部变量、函数参数、返回地址等。heap动态分配的内存。bss未初始化 或 初值为0 的全局变量和静态局部变量。data已初始化 且 初值非0 的全局变 ...