15. Go调度器系列解读(二):Go 程序启动都干了些什么?

前言

本篇文章分享 Go 调度器系列文章第二篇:Go 程序启动的整体过程。在这篇文章中,我们主要会聊到 Go 程序的启动、初始化以及第一个 G 的调度过程,并画了丰富的流程图和内存图帮助理解,你可以从中学习到以下内容:

  1. 如何寻找一个 Go 程序的执行入口?
  2. Go 程序启动过程都干了什么?
  3. m0 、g0、p 等对象的初始化过程
  4. 系统线程与 m0 是如何完成绑定的?
  5. 何时创建的第一个 goroutine,其任务函数是什么?
  6. m0 线程的调度启动过程
  7. main 函数从调用到结束的过程

Go 调度器系列文章:

13. 入门 go 语言汇编,看懂 GMP 源码

14. Go调度器系列解读(一):什么是 GMP?

解读的源码环境:

Go 版本 1.20.7、linux 系统

源码地址如下:

src/runtime/runtime2.go

src/runtime/proc.go

src/runtime/asm_amd64.s

想一起学习 Go 语言进阶知识的同学可以 点赞+关注+收藏 哦!后续将继续更新 GMP 相关源码分享。

1.程序入口

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

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

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

在 Go 程序执行到启动程序入口点之前,Go 运行时系统会进行一些初始化的准备工作,包括加载和链接依赖的包等,在这个过程中,runtime·m0 和 runtime·g0 等全局变量也会被初始化。

随后 Go 程序开始从程序入口开始执行。Go 程序的执行入口并不是 runtime.main 函数,关于程序入口这个问题,之前在 13. 入门 go 语言汇编,看懂 GMP 源码 文章中专门讨论过,我们来复习一下(这里使用的 macOS 系统)。

第一步 :首先准备 main.go 文件,安装 gdb 调试程序(安装过程:mac gdb 安装避坑指南):

go 复制代码
package main

import "fmt"

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

第二步 :编译源码 go build -gcflags=all="-N -l" -ldflags=-compressdwarf=false -o main main.go

第三步:执行 gdb main 进入程序调试,执行 info files 查看程序入口,程序入口在 0x1068260 地址处;

第四步 :在程序入口设置断点进行调试 break *0x1068260 ,设置断点的时候,就已经知道程序入口在 src/runtime/rt0_darwin_amd64.s文件的第 8 行。

第五步:执行 run 运行程序,程序会停到断点的地方,就能顺着文件名字找到对应的汇编文件了:

  • macOS 系统对应的 go 程序入口文件为 src/runtime/rt0_darwin_amd64.s
  • linux 系统对应的 go 程序入口文件为 src/runtime/rt0_linux_amd64.s

好的,通过以上步骤我们已经找到了程序真正的入口:

源码:runtime/asm_amd64.s 15

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

_rt0_amd64 函数逻辑简单:转存参数 argc 值和 argv 数组的地址到 DI、SI 寄存器,然后跳转到 runtime·rt0_go(SB) 函数中执行。

rt0_go 就是我们的核心启动函数了,从 159 ~ 377 共 218 行汇编代码,接着就让我们看看 rt0_go 都干了些什么吧!

2.rt0_go 函数初始化 go 程序

先整体看一下 rt0_go 函数的主要执行逻辑(省去很多和调度无关的逻辑),下面将对这些主要逻辑一一讲解:

2.1 第一步:接收命令行参数

源码:runtime/asm_amd64.s 159

调整栈顶寄存器的值并使其按16字节对齐,把 argc 和 argv 搬到新的位置

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

2.2 第二步:初始化 g0 栈内存

源码:runtime/asm_amd64.s 168

初始化全局变量 g0 的栈内存,从主线程的栈分出一部分当作 g0 的栈。g0 的主要作用是提供一个栈供 runtime 代码执行,这里主要对 g0 的几个与栈有关的成员进行了初始化,g0 的栈大约有 64K,地址范围为 SP + (-64*1024 + 104) ~ SP。

go 复制代码
	// create istack out of the given (operating system) stack.
	// _cgo_init may update stackguard.
	MOVQ	$runtime·g0(SB), DI // DI = g0
	LEAQ	(-64*1024+104)(SP), BX
	MOVQ	BX, g_stackguard0(DI) // g0.g_stackguard0 = BX
	MOVQ	BX, g_stackguard1(DI) // g0.g_stackguard1 = BX
	MOVQ	BX, (g_stack+stack_lo)(DI) // g0.stack.lo = BX
	MOVQ	SP, (g_stack+stack_hi)(DI) // g0.stack.hi = SP

初始 g0 栈内存后,内存图如下:

这里省去很多判断 CPU 和 不同操作系统相关的代码逻辑分析,直接进入 Go 调度的正题部分!

2.3 第三步:m0 与主线程绑定

这里想讲一个重要知识点:线程本地存储。

线程本地存储是一种机制,允许每个线程存储其自己的私有数据副本,而不会与其他线程共享数据。在 x86 架构中,fs 寄存器是一个特殊的寄存器,通常用于访问线程局部存储。通过将 TLS 数据存储在 fs 寄存器指向的地址空间中,每个线程都可以独立地访问和修改自己的数据副本,而不会与其他线程的数据发生冲突。

2.3.1 初始化线程本地存储

在 Go 调度器中 fs 寄存器指向的地址空间为 m.tls[1],用于存储 TLS 数据,m.tls[0] 一般存储的是当前使用的 g(无论是 g0 或其他 g),通过 g.m 就能绑定系统线程和 m 的关系。接下来我们就来看一看系统线程是如何绑定 m.tls[1]。

源码:runtime/asm_amd64.s 258

go 复制代码
	LEAQ	runtime·m0+m_tls(SB), DI // DI = &m0.tls
	// 调用 settls 设置线程本地存储,settls 函数的参数在 DI 寄存器中
	CALL	runtime·settls(SB) // 设置 tls = m.tls[1] 的地址

	// store through it, to make sure it works
	// 验证 TLS 功能是否正常,如果不正常则直接 abort 退出程序
	// 获取 fs 段基地址并放入 BX 寄存器,
	// 如果功能正常,则 BX 应该和 m0.tls[1] 指向同一个地址,
	// get_tls 的代码由编译器生成
	get_tls(BX)
	// 因为 m 在堆上分配,g(BX) BX ~ BX-8 存储 0x123
	// 把整型常量 0x123 拷贝到 fs 段基地址偏移 -8 的内存位置,也就是m0.tls[0] = 0x123
	MOVQ	$0x123, g(BX) 
	MOVQ	runtime·m0+m_tls(SB), AX // AX = m0.tls[0]
	// 检查 m0.tls[0] 的值是否是通过线程本地存储存入的 0x123 来验证 tls 功能是否正常
	CMPQ	AX, $0x123 
	JEQ 2(PC)
	CALL	runtime·abort(SB) // 如果线程本地存储不能正常工作,退出程序

这段代码主要逻辑如下:

  1. 首先调用 settls 函数初始化主线程的线程本地存储( fs 段寄存器基地址 = m0.tls[1]),目的是把m0 与主线程关联在一起;
  2. 设置了线程本地存储之后,接下来的几条指令在于验证 TLS 功能是否正常,如果不正常则直接 abort 退出程序。

拓展知识

简单解释一下 get_tls(BX) 指令:用于获取当前线程的 TLS 数据,并将其地址返回给调用者。

在源码中我们是看不到 get_tls 函数的实现的,编译器会处理源代码中的 get_tls(BX) 函数调用,将其转换为相应的汇编指令。编译器通常会将函数调用转换为库函数的调用,这些库函数是为特定平台优化的,以提供高效且可靠的线程局部存储访问。由于平台不同会转换为为不同的库函数,所以需要编译器动态生成。比如在 Linux 系统中,常用的库函数是 __get_tls(),这是一个由 GCC 编译器提供的内建函数。__get_tls() 函数用于获取当前线程的 TLS 数据,并将其地址返回给调用者。

settls 函数属于拓展知识内容,不感兴趣可以跳过!

源码:runtime/sys_linux_amd64.s 633

go 复制代码
// set tls base to DI = &m0.tls
TEXT runtime·settls(SB),NOSPLIT,$32
#ifdef GOOS_android
	// Android stores the TLS offset in runtime·tls_g.
	SUBQ	runtime·tls_g(SB), DI
#else
	// 除安卓以外系统,ELF (可执行文件格式)中的 TLS 实现的机制需要 +8
	// 对堆内存来说 +8 相当于由 tls[0] -> tls[1] 
	ADDQ	$8, DI	// ELF wants to use -8(FS)
#endif
	MOVQ	DI, SI // SI 存放 arch_prctl 系统调用的第二个参数
	// DI 存入 ARCH_SET_FS 参数,用于标志设置 TLS,arch_prctl 的第一个参数
	MOVQ	$0x1002, DI	
	MOVQ	$SYS_arch_prctl, AX // AX 存入系统调用
	SYSCALL
	CMPQ	AX, $0xfffffffffffff001 // 是否系统调用成功
	JLS	2(PC)
	MOVL	$0xf1, 0xf1  // crash 系统调用失败
	RET

2.3.2 系统线程绑定 m0

视角继续转换回 rt0_go 函数:

源码:runtime/asm_amd64.s 268

go 复制代码
ok:
	// set the per-goroutine and per-mach "registers"
	get_tls(BX) // 获取存储 TLS 数据的地址
	LEAQ	runtime·g0(SB), CX // CX = &g0
	MOVQ	CX, g(BX) // m0.tls[0] = &g0; TLS 数据存储 &g0
	LEAQ	runtime·m0(SB), AX // AX = &m0

	// save m->g0 = g0
	MOVQ	CX, m_g0(AX)
	// save m0 to g0->m
	MOVQ	AX, g_m(CX)

这段代码主要逻辑如下:

  1. 首先将主线程的 TLS 存储为 &g0,保存在主线程本地存储中的值是 g0 的地址,也就是说工作线程的私有全局变量其实是一个指向 g 的指针,而不是指向 m 的指针,目前这个指针指向 g0,表示代码正运行在 g0 栈,当切换为普通 g 栈执行用户代码时,自然需要设置 TLS = &g。
  2. 接着绑定了 m0 和 g0 之间的关系,这样系统线程就可以通过 TLS(g0).m0 找到 m0 了,对于普通线程而言,也可以使用 TLS(g).m 找到对应的 m,这样 m 就和系统线程完成了绑定。

此时,主线程,m0、g0 以及 g0 的栈之间的关系如下图所示:

2.4 第四步:处理命令行参数

跳过 282~341 的代码,暂时不需要关注。接下来是处理命令行参数的函数 args。

源码:runtime/asm_amd64.s 343

go 复制代码
	MOVL	24(SP), AX		// copy argc
	MOVL	AX, 0(SP) 		// argc 放在栈顶
	MOVQ	32(SP), AX		// copy argv
	MOVQ	AX, 8(SP) 		// argv 放在 SP + 8 的位置
	CALL	runtime·args(SB) // 处理操作系统传递过来的参数

args 具体作用是给 runtime 包下的 argc、argv 等全局变量赋值。源码具体解析在第3小节:处理命令行参数部分。(这部分源码与调度无关,但我没忍住阅读了一下,我承认我跑偏了,不感兴趣的可以跳过第四步!)

2.5 第五步:初始化操作系统

对于 Linux 来说,osinit 函数功能就是获取操作系统的参数设置,例如:获取 CPU 的核数并放在 global 变量 ncpu 中,后边初始化 P 的数量的时候会用到。

源码:runtime/asm_amd64.s 348

go 复制代码
CALL	runtime·osinit(SB) 

在 runtime·osinit 中主要是获取CPU数量、页大小和操作系统初始化工作。

源码:runtime/os_linux.go 341

go 复制代码
func osinit() {
	ncpu = getproccount()
	physHugePageSize = getHugePageSize()
	...
	osArchInit()
}

2.6 第六步:调度器初始化(M0、P)

接下来就是重点内容了,初始化调度器参数,包括 m0、allp 等!详细源码解析在第 4 小节:调度器初始化 schedinit 函数。

源码:runtime/asm_amd64.s 349

go 复制代码
CALL	runtime·schedinit(SB)

2.7 第七步:创建第一个 goroutine(G)

该块汇编代码调用了 newproc 函数,创建了一个新的 goroutine,也是 go 程序第一个 goroutine。第一个 goroutine 也被叫做 main goroutine,用于运行我们的 main 函数。newproc 详细讲解参照第5小节:创建第一个 goroutine。

源码:src/runtime/asm_amd64.s 351

go 复制代码
	// create a new goroutine to start program
	MOVQ	$runtime·mainPC(SB), AX		// entry mainPC 变量 = runtime·main 函数
	PUSHQ	AX // newproc 的参数入栈
	CALL	runtime·newproc(SB) // 调用 newproc 创建 goroutine
	POPQ	AX // 参数出栈

这里解释一下 mainPC 变量的定义:mainPC 变量 = runtime·main 函数

源码:src/runtime/asm_amd64.s 379

go 复制代码
// mainPC is a function value for runtime.main, to be passed to newproc.
// The reference to runtime.main is made via ABIInternal, since the
// actual function (not the ABI0 wrapper) is needed by newproc.
// mainPC 变量 = runtime·main 函数
DATA	runtime·mainPC+0(SB)/8,$runtime·main<ABIInternal>(SB) 
GLOBL	runtime·mainPC(SB),RODATA,$8 // 声明全局变量

2.8 第八步:启动 M0 开始 GMP 调度

rt0_go 函数最后一步,启动 m0,开启 GMP 的调度循环,mstart 函数详解参考第六小节:启动 M0 开始 GMP 调度。GMP 模型启动后,会进入调度循环,mstart 函数一般是不会返回的。

源码:src/runtime/asm_amd64.s 357

go 复制代码
	// start this M
	// 主线程进入调度循环,运行刚刚创建的 goroutine 
	CALL	runtime·mstart(SB) 

	// 上面的 mstart 函数永远不应该返回的,如果返回了,一定是代码逻辑有问题,直接 abort
	CALL	runtime·abort(SB)	// mstart should never return
	RET

3.处理命令行参数

关键代码:CALL runtime·args(SB)对应下面这个函数,该函数主要对 argc、argv 等全局变量赋值。

源码:src/runtime/runtime1.go 66

go 复制代码
func args(c int32, v **byte) {
	argc = c
	argv = v
	sysargs(c, v)
}

那 argc、argv 变量如何使用呢?

随后在接下来我们要讲到的 schedinit 函数中,调用了 goargs 函数,初始化了 argslice 变量。

go 复制代码
func goargs() {
	if GOOS == "windows" {
		return
	}
	argslice = make([]string, argc)
	for i := int32(0); i < argc; i++ {
		argslice[i] = gostringnocopy(argv_index(argv, i))
	}
}

那包级别私有的全局变量 argslice 又该如何使用呢?我们顺着 argslice 找到了 runtime 包下的 os_runtime_args 函数,这里把 argslice 返回了,使用了 go:linkname 连接到了 os.runtime_args 函数的实现上,也就是说 os.runtime_args 函数的实现等于 runtime.os_runtime_args,这样就绕开了私有函数和私有变量的限制。

接着看 os.Args 初始化方式在 init 函数中,被赋值到了 Args 全局变量,后边就直接能用了。

go 复制代码
// 供业务使用 runtime/runtime.go 60
//go:linkname os_runtime_args os.runtime_args
func os_runtime_args() []string { return append([]string{}, argslice...) }

// 源码地址:os/proc.go 16
// Args hold the command-line arguments, starting with the program name.
var Args []string

func init() {
	if runtime.GOOS == "windows" {
		// Initialized in exec_windows.go.
		return
	}
	Args = runtime_args()
}

func runtime_args() []string // in package runtime

接下来我们讲述一下业务该如何使用吧!业务通过: go run main.go arg1 arg2 运行如下代码,就能获取到命令行参数,这样业务就能使用了!

go 复制代码
package main

import (
	"fmt"
	"os"
)

func main() {
	args := os.Args

	// 遍历命令行参数并打印它们的值
	for _, arg := range args {
		fmt.Println("参数:", arg)
	}
}

4.调度器初始化 schedinit 函数

对照 2.6 小节内容,这里分析 schedinit 函数的主要逻辑:

源码:src/runtime/proc.go 669

注释中解释:golang 的 bootstrap(启动)流程步骤分别是 call osinit、call schedinit、make & queue new G 和 call runtime·mstart 四个步骤。当前我们正处于 call schedinit 步骤。

go 复制代码
// The bootstrap sequence is:
//
//	call osinit
//	call schedinit
//	make & queue new G
//	call runtime·mstart
//
// The new G calls runtime·main.
func schedinit() {

    // 一些列 lock(锁) 初始化
    ...

    // getg 函数在源代码中没有对应的定义,由编译器插入代码
    // get_tls(CX) 
    // MOVQ g(CX), BX
	gp := getg() // 获取当前 tls 中的 g,目前是 g0

    ...

	sched.maxmcount = 10000 // 设置最多启动 10000 个操作系统线程,也是最多 10000 个M

	...

	// 栈、内存分配器相关初始化
	stackinit() // 初始化栈
	mallocinit() // 初始化内存分配器

    ...

    // 初始化当前系统线程 M0
	mcommoninit(gp.m, -1)

    ...
    
	goargs() // 这个第四步讲过,存储命令行参数到全局变量
	goenvs() // 初始化 go 环境变量

	...
    
	gcinit() // 初始化 GC

	...

	lock(&sched.lock)
	sched.lastpoll.Store(nanotime()) // 初始化上次网络轮询的时间
	procs := ncpu //系统中有多少核,就创建和初始化多少个 P 结构体对象
	if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
		procs = n // 设置 P 的个数为 GOMAXPROCS 
	}
    // procresize 创建和初始化全局变量 allp
	if procresize(procs) != nil {
		throw("unknown runnable goroutine during bootstrap")
	}
	unlock(&sched.lock)

	...
}

schedinit 函数主要逻辑:

  1. 初始化各种锁
  2. 会设置 M 最大数量为 10000,一般实际中不会达到
  3. 堆栈内存分配器相关初始化(Go 内存管理相关)
  4. 调用 mcommoninit 函数初始化当前系统线程 M0(重点内容)
  5. 设置命令行参数、go 环境参数
  6. 初始化 GC
  7. 将 P 个数设置为 GOMAXPROCS 的值,即程序能够同时运行的最大处理器数
  8. 调用 procresize 函数创建和初始化全局变量 allp(重点内容)

从上述源码中我们可以看到,P 的数量取决于当前 cpu 的数量,或者是 GOMAXPROCS 的配置。不少 golang 的同学都有一种错误的认知,认为 GOMAXPROCS 限制的是 golang 中的线程数,这个认知是错误的。GOMAXPROCS 真正制约的是 GMP 中 P 的数量,而不是 M。其实 P 的数量,控制了并行的能力,一个 M 绑定一个 P, 如果 M 数量大于 P,多出来的 M 就只有阻塞排队。所以最好就是有几核就设置几个 P。

schedinit 函数中最重要的两个逻辑就是 mcommoninit(gp.m, -1) 函数和 procresize(procs) 函数,接下来我们详细分析一下源码。

4.1 初始化 M0

先来看一下 mcommoninit(gp.m, -1) 函数源码,主要逻辑是初始化 m0 的一些属性,并将 m0 放入全局链表 allm 之中,过程比较简单。

源码:src/runtime/proc.go 810

go 复制代码
// Pre-allocated ID may be passed as 'id', or omitted by passing -1.
func mcommoninit(mp *m, id int64) {
	...

	lock(&sched.lock)

    // 初始化 m 的 id 属性
    
    if id >= 0 {
		mp.id = id
	} else {
        // 检查已创建系统线程是否超过了数量限制(10000)
        // id 在 sched.mnext 存着
		mp.id = mReserveID()
	}
    
    ...

	mpreinit(mp) // 创建用于信号处理的 gsignal,从堆上分配一个 g 结构体对象,并设置栈内存
	if mp.gsignal != nil {
		mp.gsignal.stackguard1 = mp.gsignal.stack.lo + _StackGuard
	}

	// 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  // 把 m 挂入全局链表 allm 之中

    ...
    
    unlock(&sched.lock)
	...
}

4.2 创建和初始化全局变量 allp

再来简单看下 procresize,这个函数会创建和初始化 p 结构体对象:创建指定个数的 p 结构体对象,放在全变量 allp 里, 并把 m0 和 allp[0] 绑定在一起,这里记一下,m0 已经绑定 p 了,后续就不用绑定了。

这个源码看起来很复杂,是因为初始化完成之后用户代码还可以通过 GOMAXPROCS() 函数调用它,重新创建和初始化 p 结构体对象,运行过程中需要处理的情况比单纯初始化复杂的多,这里我们简单理解一下初始化代码就可以了。

源码:src/runtime/proc.go 4956

go 复制代码
func procresize(nprocs int32) *p {
	...

	old := gomaxprocs // 系统初始化时 old = gomaxprocs = 0

    ...

	// Grow allp if necessary.
    // 初始化时 len(allp) == 0
	if nprocs > int32(len(allp)) {
		// Synchronize with retake, which could be running
		// concurrently since it doesn't run on a P.
		lock(&allpLock)
		if nprocs <= int32(cap(allp)) {
            // 用户代码对 P 数量进行缩减
			allp = allp[:nprocs]
		} else {
            // 这里是初始化
			nallp := make([]*p, nprocs)
			// 将所有内容复制到 allp 的上限,这样我们就不会丢失旧分配的 P。
			copy(nallp, allp[:cap(allp)])
			allp = nallp
		}
		...
		unlock(&allpLock)
	}

	// initialize new P's
    // 循环创建新 P,直到 nprocs 个
	for i := old; i < nprocs; i++ {
		pp := allp[i]
		if pp == nil {
			pp = new(p)
		}
		pp.init(i) // 初始化 p 属性,设置 pp.status = _Pgcstop
		atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
	}

	gp := getg() // g0
	if gp.m.p != 0 && gp.m.p.ptr().id < nprocs {
		// continue to use the current P
		gp.m.p.ptr().status = _Prunning
		gp.m.p.ptr().mcache.prepareForSweep()
	} else {
        // 初始化会走这个分支
		...
		gp.m.p = 0
		pp := allp[0]
		pp.m = 0
		pp.status = _Pidle // 把 allp[0] 设置为 _Pidle
		acquirep(pp) // 把 allp[0] 和 m0 关联起来,设置为 _Prunning
		...
	}

	...

	var runnablePs *p
    // 下面这个for 循环把所有空闲的 p 放入空闲链表
	for i := nprocs - 1; i >= 0; i-- {
		pp := allp[i]
		if gp.m.p.ptr() == pp { // allp[0] 保持 _Prunning
			continue
		}
		pp.status = _Pidle // 初始化其他 p 都为 _Pidle
		if runqempty(pp) {
			pidleput(pp, now) // 放入 sched.pidle P 空闲链表,都是链表操作
		} else {
			...
		}
	}

    ...
    
	return runnablePs
}

总结一下这个函数初始化的主要流程:

  1. 使用 make([]*p, nprocs) 初始化全局变量 allp,即 allp = make([]*p, nprocs);
  2. 循环创建、初始化 nprocs 个 p 结构体对象,此时 p.status = _Pgcstop,依次保存在 allp 切片之中;
  3. 先把 allp[0] 状态设置为 _Pidle,然后把 m0 和 allp[0] 关联在一起,即 m0.p = allp[0] , allp[0].m = m0,此时设置 allp[0] 的状态 _Prunning;
  4. 循环 allp[0] 之外的所有 p 对象,设置 _Pidle 状态,并放入到全局变量 sched 的 pidle 空闲队列之中,链表使用 p.link 进行连接。(后续使用可以参考《 14. Go调度器系列解读(一):什么是 GMP?》 3.2 小节:P 的唤醒)

至此,调度器初始化完成,这时整个调度器相关的各组成部分之间的联系如下图所示:

5.创建第一个 goroutine

对照 2.7 小节内容,这里分析一下 newproc 函数源码,这部分源码其实已经在文章《 14. Go调度器系列解读(一):什么是 GMP?》 3.1 小节(G 的创建)中详细分析过一次,这里简单回顾一下:

源码:src/runtime/proc.go 4259

go 复制代码
// Create a new g running fn.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
func newproc(fn *funcval) {
	gp := getg()
	pc := getcallerpc() // 获取 newproc 函数调用者指令的地址
	systemstack(func() {
		newg := newproc1(fn, gp, pc) // 创建 G

		pp := getg().m.p.ptr() // 获取当前绑定的 p
		runqput(pp, newg, true) // 将 G 放入运行队列

		if mainStarted { // 如果 main 函数已经启动
			wakep()
		}
	})
}

newproc 函数用于创建新的 goroutine,它有一个参数 fn 表示 g 创建出来要执行的函数,这里 fn = runtime·main,接下来我们分析一下 newproc 函数的内容。

第一步:切换运行栈

使用 systemstack 函数切换到系统栈(一般是 g0 栈)中执行,执行完毕后切回普通 g 的栈,由于我们本身就在 g0 栈中,不需要进行切换,直接执行代码即可。

第二步:创建 goroutine

newproc1 用于创建一个新可运行的 goroutine,主要是内存分配和 g 参数的初始化,这里和普通的 goroutine 初始化基本一致。

  1. 使用 malg 函数创建一个 newg,此时 g 为 __Gidle,malg 为 newg 申请完内存后,更改其状态为 _Gdead。goroutine 的栈(g.stack)是在堆上分配的,而不是在栈内存中。由于这个栈是在堆上动态分配的,意味着它的大小可以在运行时改变。与栈内存不同,堆内存的分配和释放更加灵活,可以在运行时动态调整。这种设计使得 goroutine 的栈可以随着程序的需要而增长和缩小,从而实现更好的性能和资源利用率。
  2. 使用 gostartcallfn 函数对 newg 的 sched 成员进行初始化:newg.sched.sp 指向 newg 的栈顶,栈内存入了 return address = goexit+1;newg.sched.pc 指向 runtime·main 函数的第一条指令
  3. 更改 newg 状态为 _Grunnable,生成 goid,返回 newg。

第三步:放入可运行队列

获取当前 m0 的 p,本场景下这里的 p = allp[0],且 p 的本地运行队列为空,优先将 newg 放入该 p 的 p.runnext,后边都不用执行了。

第四步:唤醒一个 P

本场景下,main 函数还没有启动,因此也不用执行!

经过这四个步骤后,一个新的 G 被创建出来,状态为 _Grunnable,并且被放入了 allp[0] 的 runnext 字段中,后边就等着被调度执行了,到此程序中第一个真正意义上的 goroutine 已经创建完成,该 goroutine 的执行函数为 runtime·main,此时 goroutine 还没有和任何 m 进行绑定。对应的内存关系图如下:

6.启动 M0 开始 GMP 调度

对照 2.8 小节内容,这里分析一下 runtime·mstart 函数源码,这部分源码也已经在文章《 14. Go调度器系列解读(一):什么是 GMP?》 4 章节(一个线程的基本调度流程)中详细分析过一次,这里就不再赘述了。

源码:src/runtime/proc.go 1419

这里用一张图简单回顾一下 M0 线程的调度流程:

CALL runtime·mstart(SB) 具体执行流程总结:

  1. mstart 直接调用了 mstart0 函数,mstart0 函数在初始化 g0 的 stackguard0、stackguard1 属性后,继续调用了 mstart1 函数。
  2. mstart1 函数中设置 g0.sched.sp 和 g0.sched.pc 等调度信息,其中 g0.sched.sp 指向 mstart1 函数栈帧的栈顶(如上图所述),g0.sched.pc 指向 mstart1 函数执行完的返回地址。由于 m0 已经绑定了 p,所以可以直接调用 schedule 函数,开始调度流程。
  3. 在 schedule 函数中根据调度策略(findRunnable 函数,下一篇文章会分享源码解读)选择一个可运行的 g;随后调用 execute 函数,执行调度。目前唯一能被调度的就是刚刚创建的 main G,因此该 goroutine 被调度执行,状态变为 _Grunning。
  4. 由于 goroutine 执行用户程序需要在自己的栈内,因此使用 gogo 函数将当前 g0 栈切换为 g 栈,gogo 主要逻辑如下:
    1. 首先将线程 tls 的 g0 替换为了 g;
    2. 然后通过设置 CPU 的栈顶寄存器 SP 为 g.sched.sp,实现了从 g0 栈到 g 栈的切换;保存了其他 gobuf 内的寄存器到 CPU 对应的寄存器,为后续调用 g 做准备;
    3. 最后从 g 中取出 g.sched.pc 的值,并通过 JMP 指令从 runtime 代码直接跳转到用户代码执行,完成了 CPU 执行权的转让。
  5. 在 2.7 小节创建 g 的时候,将 g.sched.pc 指向 runtime·main 函数的第一条指令,gogo 函数中使用 JMP 指令跳转到 g.sched.pc 开始执行,此时 g 真正开始执行 runtime.main 函数。
  6. 在 runtime.main 函数源码中会调用 main_main 函数,在编译期间会动态的链接到我们写的 main 函数,因此我们业务的 main 函数开始执行。
  7. 与普通 goroutine 不同的是:main g 执行完用户程序中的 main 函数,还会继续返回到 runtime.main 函数继续往下执行,接着就执行 exit(0) 退出进程了,并没有像普通 g 一样返回到 return address = goexit+1 处,也就不会继续执行调度循环。
  8. 进程退出,程序自然也就结束了。

接下来,我们聊一下 runtime.main 函数都干了些什么!

7.runtime·main 函数

此时,CPU 开始运行 runtime.main 函数,创建 g 的时候,塞入的任务函数,当 g 被调度运行时,CPU 执行的下一条指令,就是 g 的任务函数。当前 main g 的任务函数为 runtime.main 函数。

源码:src/runtime/proc.go 145

go 复制代码
// The main goroutine.
func main() {
	mp := getg().m // m0

	...

	if goarch.PtrSize == 8 {
        // 64 位系统上每个 goroutine 的栈最大可达 1G
		maxstacksize = 1000000000
	} else {
        // 250 MB on 32-bit.
		maxstacksize = 250000000
	}

	...

	// Allow newproc to start new Ms.
    // newproc 新建 G 的时候,决定要不要唤醒一个 P
	mainStarted = true

	if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
        // 在系统栈上运行 sysmon
		systemstack(func() {
            // 创建监控线程,该线程独立于调度器,不需要跟 p 关联即可运行
            // mstart 时会通过调用 g.m.mstartfn 执行 sysmon
            // 然后监控线程就运行起来了,而且不会停止
			newm(sysmon, nil, -1)
		})
	}

	...

	// runtime 内部 init 函数的执行,由编译器实现
	doInit(&runtime_inittask) // Must be before defer.

	...

	gcenable() // 开启垃圾回收器

	...
    
    // main 包的初始化函数 init,也是由编译器实现,会递归的调用我们import进来的包的初始化函数
	doInit(&main_inittask)

    // 调用 main.main 函数,也就是业务的 main 函数
	fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
	fn()

    ...

    // 进入系统调用,退出进程,可以看出 main goroutine 并未返回
    // 而是直接进入系统调用退出进程了
	exit(0)
    // 保护性代码
	for {
		var x *int32
		*x = 0
	}
}

通过对主要代码的梳理,可以把 runtime.main 函数的主要工作流程总结如下:

  1. 根据系统定义每个 goroutine 的栈空间最大值;
  2. 设置 main 函数已经启动的标志,mainStarted = true;
  3. 启动一个 sysmon 系统监控线程,该线程负责整个程序的 gc、抢占调度以及 netpoll 等功能的监控;
  4. 执行 runtime 包的初始化函数;
  5. 开启垃圾回收器的 goroutine;
  6. 执行 main 包以及 main 包 import 的所有包的初始化(通过递归,初始化所以相关包);
  7. 执行 main_main 函数,也就是业务程序的 main 函数;
  8. 从 main_main 函数返回后调用 exit 系统调用退出进程,至此程序结束。

总结

洋洋洒洒又写了一篇关于 GMP 的知识点,越写越感觉根本写不完,无奈只能拆成不同的角度进行讲述,本篇文章侧重点是 Go 程序的启动过程,总结一下重点知识:

  1. 利用 gdb 调试工具寻找 Go 程序入口,我们找到了 runtime·rt0_go(SB) 汇编函数。
  2. 分析了 runtime·rt0_go(SB) 函数的主要逻辑流程,rt0_go 函数主要是进行系统的初始化工作和启动调度线程:
    1. 命令行参数的接收和处理;
    2. 初始化 g0 栈内存;初始化主线程本地存储,并与 m0 进行绑定,线程 tls 对应 m.tls[1] 地址,m0.tls[0] 中存入 &g0,这样以后可以通过系统线程的 tls.g.m 就可以找到对应的 m;g0 和 m0 互相绑定;
    3. 操作系统初始化,主要作用是获取 CPU 的核数并放在 global 变量 ncpu 中;
    4. 调度器初始化:初始化 m0 的一些属性,并将 m0 放入全局链表 allm 之中;创建和初始化 p 结构体对象:创建指定个数的 p 结构体对象,放在全变量 allp 里, 并把 m0 和 allp[0] 绑定在一起;
    5. 调用了 newproc 函数,创建了一个新的 goroutine,也是 go 程序第一个 goroutine,该 goroutine 的任务函数指向 runtime.main 函数;
    6. 调用 mstart 函数启动 M0 线程,开启 GMP 调度;
  3. 随后聊到了 M0 的 mstart 函数启动过程,参照第 6 小节流程图,通过一系列的函数调用 mstart -> mstart0 -> mstart1 -> schedule -> execute -> gogo 将执行权交给了新的 goroutine,随后启动任务函数 runtime.main。
  4. runtime.main 函数是 main 函数真正的入口,主要逻辑如下:
    1. 在该函数中启动了一个 sysmon 系统监控线程,该线程负责整个程序的 gc、抢占调度以及 netpoll 等功能的监控;
    2. 执行 runtime init;
    3. 启动 GC 的 goroutine 负责垃圾内存回收;
    4. 执行 main 函数的 init,执行 main 包以及 main 包 import 的所有包的初始化;
    5. 执行 main 函数,执行完毕后,调用系统调用 exit 进行退出。

至此本篇文章完整的讲述了一个 Go 程序从启动到退出的全部过程,如果觉得写的还不错的话,期待你的点赞、分享持续关注

相关推荐
聪明的墨菲特i1 分钟前
Django前后端分离基本流程
后端·python·django·web3
Zfox_17 分钟前
【Linux】进程信号全攻略(二)
linux·运维·c语言·c++
安於宿命22 分钟前
【Linux】简易版shell
linux·运维·服务器
黄小耶@33 分钟前
linux常见命令
linux·运维·服务器
叫我龙翔35 分钟前
【计网】实现reactor反应堆模型 --- 框架搭建
linux·运维·网络
古驿幽情37 分钟前
CentOS AppStream 8 手动更新 yum源
linux·运维·centos·yum
BillKu38 分钟前
Linux(CentOS)安装 Nginx
linux·运维·nginx·centos
BillKu41 分钟前
Linux(CentOS)yum update -y 事故
linux·运维·centos
a266378961 小时前
解决yum命令报错“Could not resolve host: mirrorlist.centos.org
linux·运维·centos
hlsd#1 小时前
go mod 依赖管理
开发语言·后端·golang