Go1.21.0 程序启动过程

版本说明

  • Go 1.21.0
  • 操作系统:Windows11 Intel64

结论先行

开发关注版

在 Go 语言中,启动顺序通常如下:

  1. 导入包:首先,Go 编译器按照源文件中的 import 语句导入所有需要的包。
  2. 初始化常量和变量:接着,编译器会初始化包级别(全局)的常量和变量。它们的初始化顺序按照它们在源文件中出现的顺序进行。
  3. 执行 init 函数:然后,编译器会执行包级别的 init 函数。如果一个包有多个 init 函数,它们的执行顺序和它们在源文件中出现的顺序一致。
  4. 执行 main.main 函数:最后,编译器会执行 main 函数。
Go 程序启动流程 - 开发关注版

深入原理版

  1. 命令行参数复制:读取命令行参数,复制到 argc 和 argv。
  2. 初始化 g0 栈:g0 是运行时系统的一个特殊的 goroutine,它在程序启动时被创建,用于执行系统调用和协程调度。
  3. runtime.check 运行时检查
    1. 类型长度
    2. 指针操作
    3. 结构体字段偏移量
    4. CAS
    5. atomic 操作
    6. 栈大小是否为 2 的幂次。
  4. runtime.args 参数初始化:将 argc 和 argv 的参数赋值到 Go 的变量中。
  5. runtime.osinit 初始化操作系统特点的设置:主要是判断系统字长和 CPU 核数。
  6. runtime.schedinit 初始化调度器
    1. 锁初始化
    2. 竞态检测器初始化
    3. 调度器设置,设置调度器可以管理的最大线程(M)数目
    4. 系统初始化,初始化内存管理、CPU 设置、算法等,这些都是调度器正常工作的基础
    5. 设置当前 M 的信号掩码
    6. 解析程序参数和环境变量
    7. 垃圾收集器初始化
    8. 设置 process 的数量
  7. runtime.newproc 创建主协程 g0 并将其放入队列中等待执行
  8. runtime. mstart 启动调度器:初始化 m0,并调度 g0 去执行 runtime.main
  9. runtime.main 程序真正入口
    1. runtime.init
    2. 启动 gc
    3. 执行用户包 init
    4. 执行用户函数 main.main
Go 程序启动流程 - 深入原理版

如果只是想对 Go 语言程序的启动过程有一个简单的了解,那么阅读到这里就可以结束了。

Runtime

在分析 Go 程序的启动过程之前,我们需要先了解一下 Go 中的 Runtime。所谓 Runtime,即 Go 的运行时环境,可以理解为 Java 的 JVM、JavaScript 依赖的浏览器内核。

Go 的 Runtime 是一份代码,它会随着用户程序一起打包成二进制文件,随着程序一起运行。

Runtime 具有内存管理、GC、协程、屏蔽不同操作系统调用等能力。

综上,Go 程序的运行都依赖于 Runtime 运行,所以我们在分析 Go 语言程序的启动过程的时候,首先要确定程序的入口,即 Runtime。

这部分代码位于 go 源码中 src/runtime 目录下,当你在本机安装 go 后,你可以进入相应的代码目录下,在 Windows 上,你可以在该目录下运行下面命令:

1
dir | findstr "rt0" |  findstr "amd"

这里我们输出 go 官方为多种 amd 处理器架构的操作系统所实现的 runtime,如:

1
2
3
4
5
6
7
8
9
10
11
12
-a----         2023/8/25     23:44            754 rt0_android_amd64.s
-a---- 2023/8/25 23:44 399 rt0_darwin_amd64.s
-a---- 2023/8/25 23:44 448 rt0_dragonfly_amd64.s
-a---- 2023/8/25 23:44 442 rt0_freebsd_amd64.s
-a---- 2023/8/25 23:44 311 rt0_illumos_amd64.s
-a---- 2023/8/25 23:44 425 rt0_ios_amd64.s
-a---- 2023/8/25 23:44 307 rt0_linux_amd64.s
-a---- 2023/8/25 23:44 309 rt0_netbsd_amd64.s
-a---- 2023/8/25 23:44 311 rt0_openbsd_amd64.s
-a---- 2023/8/25 23:44 481 rt0_plan9_amd64.s
-a---- 2023/8/25 23:44 311 rt0_solaris_amd64.s
-a---- 2023/8/25 23:44 1166 rt0_windows_amd64.s

到这里也就明白了,前面所说的 Go Runtime 能力之 “屏蔽不同操作系统调用能力” 的方式便是针对每一种操作系统单独做实现,最后在编译的时候根据操作系统选择对应的实现即可。

这里我们以 rt0_windows_amd64.s 为例,看看这个文件写了些什么:

1
2
TEXT _rt0_amd64_windows(SB),NOSPLIT|NOFRAME,$-8
JMP _rt0_amd64(SB)

这里我们可以看到它会直接跳到 _rt0_amd64(SB) 这里,在 Goland IDE 中,你可以双击 Shift 键打开搜索,搜索 TEXT _rt0_amd64,就可以发现这个函数位于 asm_amd64.s 文件中,查看该文件:

1
2
3
4
5
6
7
8
// _rt0_amd64 is common startup code for most amd64 systems when using
// internal linking. This is the entry point for the program from the
// kernel for an ordinary -buildmode=exe program. The stack holds the
// number of arguments and the C-style argv.
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)

翻译一下上面的注释:_rt0_amd64 是大多数 amd64 系统在使用内部链接时的通用启动代码。这是 exe 程序从内核进入程序的入口点。堆栈保存了参数的数量和 C 语言风格的 argv。

到这里我们就可以非常确定地找到了对应操作系统的 Go 语言程序启动入口了,接下来只需要沿着该入口继续分析即可。

runtime·rt0_go

上面我们分析到 _rt0_adm64 会 JMP 到 runtime·rt0_go 执行,这个函数也位于 asm_amd64.s 文件中,通过分析这个函数,我们可以了解到 Go 语言程序的整个启动过程。

下面将对这整个函数进行一个概览,后面会对重点过程逐个详述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
TEXT runtime·rt0_go(SB),NOSPLIT|NOFRAME|TOPFRAME,$0
// 读取命令行参数,复制参数变量 argc 和 argv 到栈上
MOVQ DI, AX // argc
MOVQ SI, BX // argv
SUBQ $(5*8), SP
ANDQ $~15, SP
MOVQ AX, 24(SP)
MOVQ BX, 32(SP)

// 从给定的(操作系统)堆栈创建istack。
// 这是在设置 g0 的堆栈,g0 是运行时系统的一个特殊的 goroutine。
// 它在程序启动时被创建,用于执行系统调用和协程调度。
// 这里只是初始化 g0 的堆栈,还没有启动 g0。
MOVQ $runtime·g0(SB), DI
LEAQ (-64*1024)(SP), BX
MOVQ BX, g_stackguard0(DI)
MOVQ BX, g_stackguard1(DI)
MOVQ BX, (g_stack+stack_lo)(DI)
MOVQ SP, (g_stack+stack_hi)(DI)

// 检查 CPU 的厂商 ID:
// 如果没有 CPU 信息,则跳转到 nocpuinfo;
// 如果是 Intel 的 CPU,就设置 runtime·isIntel=1,否则跳到 notintel。
MOVL $0, AX
CPUID
CMPL AX, $0
JE nocpuinfo
CMPL BX, $0x756E6547 // "Genu"
JNE notintel
CMPL DX, $0x49656E69 // "ineI"
JNE notintel
CMPL CX, $0x6C65746E // "ntel"
JNE notintel
MOVB $1, runtime·isIntel(SB)

notintel: // 加载 EXA=1 的 cpuid 标志和版本信息
MOVL $1, AX
CPUID
MOVL AX, runtime·processorVersionInfo(SB)

nocpuinfo:
// 如果有 _cgo_init 就调用它
MOVQ _cgo_init(SB), AX
TESTQ AX, AX
// 如果 _cgo_init 不存在,那么跳过后面的代码,
// 直接进入到 needtls 进行 TLS 的初始化。
// TLS,全称为Thread-Local Storage(线程局部存储),
// 是操作系统提供的一种机制,允许每个线程拥有一份自己的数据副本。
// 这些数据在同一线程的所有函数中都是可见的,但对其他线程是不可见的。
// 这样,每个线程可以访问和修改自己的数据,而不会影响其他线程。
JZ needtls
// 将 setg_gcc 函数的地址加载到 SI 寄存器中。
// 这是 _cgo_init 函数的第二个参数。
MOVQ $setg_gcc<>(SB), SI // arg 2: setg_gcc
// 在使用平台的TLS时不使用这第3和第4个参数。
MOVQ $0, DX
MOVQ $0, CX
#ifdef GOOS_android
MOVQ $runtime·tls_g(SB), DX // arg 3: &tls_g
// arg 4: TLS base, stored in slot 0 (Android's TLS_SLOT_SELF).
// Compensate for tls_g (+16).
MOVQ -16(TLS), CX
#endif
#ifdef GOOS_windows
MOVQ $runtime·tls_g(SB), DX // arg 3: &tls_g
// 调整 Win64 的调用约定。
MOVQ CX, R9 // arg 4
MOVQ DX, R8 // arg 3
MOVQ SI, DX // arg 2
MOVQ DI, CX // arg 1
#endif
// 前面 MOVQ _cgo_init(SB), AX,这里就是调用 _cgo_init
CALL AX
// 在 _cgo_init 之后更新 stackguard
MOVQ $runtime·g0(SB), CX
MOVQ (g_stack+stack_lo)(CX), AX
ADDQ $const_stackGuard, AX
MOVQ AX, g_stackguard0(CX)
MOVQ AX, g_stackguard1(CX)

#ifndef GOOS_windows
JMP ok
#endif

// 针对不同操作系统对 TLS 进行设置
needtls:
#ifdef GOOS_plan9
// skip TLS setup on Plan 9
JMP ok
#endif
#ifdef GOOS_solaris
// skip TLS setup on Solaris
JMP ok
#endif
#ifdef GOOS_illumos
// skip TLS setup on illumos
JMP ok
#endif
#ifdef GOOS_darwin
// skip TLS setup on Darwin
JMP ok
#endif
#ifdef GOOS_openbsd
// skip TLS setup on OpenBSD
JMP ok
#endif

#ifdef GOOS_windows
CALL runtime·wintls(SB)
#endif

LEAQ runtime·m0+m_tls(SB), DI
CALL runtime·settls(SB)

// 检查 TLS 是否正常工作
get_tls(BX)
MOVQ $0x123, g(BX)
MOVQ runtime·m0+m_tls(SB), AX
CMPQ AX, $0x123
JEQ 2(PC)
CALL runtime·abort(SB)
ok:
//设置 g0 和 m0 和 TLS
get_tls(BX)
LEAQ runtime·g0(SB), CX
MOVQ CX, g(BX)
LEAQ runtime·m0(SB), AX
MOVQ CX, m_g0(AX)
MOVQ AX, g_m(CX)
CLD

// 下面的 ifdef NEED_xxx 主要是在检查 CPU 是否支持 Go 运行时系统需要的特性。
// 我们需要在设置了 TLS 之后做这个,
// 如果失败就跳转到 bad_cpu 报告错误。
#ifdef NEED_FEATURES_CX
MOVL $0, AX
CPUID
CMPL AX, $0
JE bad_cpu
MOVL $1, AX
CPUID
ANDL $NEED_FEATURES_CX, CX
CMPL CX, $NEED_FEATURES_CX
JNE bad_cpu
#endif

#ifdef NEED_MAX_CPUID
MOVL $0x80000000, AX
CPUID
CMPL AX, $NEED_MAX_CPUID
JL bad_cpu
#endif

#ifdef NEED_EXT_FEATURES_BX
MOVL $7, AX
MOVL $0, CX
CPUID
ANDL $NEED_EXT_FEATURES_BX, BX
CMPL BX, $NEED_EXT_FEATURES_BX
JNE bad_cpu
#endif

#ifdef NEED_EXT_FEATURES_CX
MOVL $0x80000001, AX
CPUID
ANDL $NEED_EXT_FEATURES_CX, CX
CMPL CX, $NEED_EXT_FEATURES_CX
JNE bad_cpu
#endif

#ifdef NEED_OS_SUPPORT_AX
XORL CX, CX
XGETBV
ANDL $NEED_OS_SUPPORT_AX, AX
CMPL AX, $NEED_OS_SUPPORT_AX
JNE bad_cpu
#endif

#ifdef NEED_DARWIN_SUPPORT
MOVQ $commpage64_version, BX
CMPW (BX), $13 // cpu_capabilities64 undefined in versions < 13
JL bad_cpu
MOVQ $commpage64_cpu_capabilities64, BX
MOVQ (BX), BX
MOVQ $NEED_DARWIN_SUPPORT, CX
ANDQ CX, BX
CMPQ BX, CX
JNE bad_cpu
#endif

// 检查完 AMD64 不同操作系统是否支持 Go 运行时系统需要的特性后,
// 这里执行 runtime·check 对代码做一下运行时检查。
CALL runtime·check(SB)

// 复制 argc(命令行参数的数量)到 AX 寄存器,
// 然后把 AX 寄存器的值存到栈上。
MOVL 24(SP), AX
MOVL AX, 0(SP)
// 复制 argv(命令行参数的数组)到 AX 寄存器,
// 然后把 AX 寄存器的值存到栈上。
MOVQ 32(SP), AX
MOVQ AX, 8(SP)
// 调用 runtime·args 函数处理命令行参数。
CALL runtime·args(SB)
// 调用 runtime·osinit 函数初始化操作系统特定的设置。
CALL runtime·osinit(SB)
// 调用 runtime·schedinit 函数初始化调度器。
CALL runtime·schedinit(SB)

/**
补充:这是该文件下面对 runtime·mainPC 的声明
// 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.
DATA runtime·mainPC+0(SB)/8,$runtime·main<ABIInternal>(SB)
*/

// 取 runtime·mainPC 的地址,这其实就是 runtime 包下的 main() 方法。
// 它是 Go 语言程序的真正入口,而不是 main.main()。
MOVQ $runtime·mainPC(SB), AX
PUSHQ AX
// 创建一个新的 goroutine 来运行程序的主函数。
// 这里还没有正在的运行,因为调度器还没有启动,
// 只是将 runtime.main 放进 goroutine 的 queue 中等待执行。
CALL runtime·newproc(SB)
POPQ AX

// 调用 runtime·mstart 函数启动 M(machine,代表一个操作系统线程),
// 开始执行 goroutines。
CALL runtime·mstart(SB)

// 如果 runtime·mstart 函数返回,那么就调用 runtime·abort 函数终止程序。
// 因为 runtime·mstart 函数在正常情况下是不应该返回的,如果返回了,说明有错误发生。
CALL runtime·abort(SB) // mstart should never return
RET

bad_cpu: // 当前 CPU 不支持 Go 运行时系统需要的时候的错误报告。
MOVQ $2, 0(SP)
MOVQ $bad_cpu_msg<>(SB), AX
MOVQ AX, 8(SP)
MOVQ $84, 16(SP)
CALL runtime·write(SB)
MOVQ $1, 0(SP)
CALL runtime·exit(SB)
CALL runtime·abort(SB)
RET

// Prevent dead-code elimination of debugCallV2, which is
// intended to be called by debuggers.
MOVQ $runtime·debugCallV2<ABIInternal>(SB), AX
RET

整理一下,runtime·rt0_go 的大体过程如下:

  1. 读取命令行参数,复制参数变量 argc 和 argv 到栈上。
  2. 初始化 g0 栈,g0 是为了调度协程而产生的协程,是 g0 是运行时系统的一个特殊的 goroutine,它在程序启动时被创建,用于执行系统调用和协程调度。
  3. 获取 CPU 信息。
  4. 如果存在 _cgo_init,这调用它。
  5. 检查并设置线性局部存储(TLS)。
  6. 检查 CPU 是否支持 Go 运行时系统需要的特性。
  7. 完成运行时系统检查和初始化:
    • 调用 runtime·check 对代码进行运行时检查。
    • 调用 runtime·args 函数处理命令行参数。
    • 调用 runtime·osinit 函数初始化操作系统特定的设置。
    • 调用 runtime·schedinit 函数初始化调度器。
  8. 调用 runtime·newproc 创建一个新的 goroutine 来运行程序的主函数。
  9. 调用 runtime·mstart 启动当前的 machine,执行 goroutines,执行程序。

一句话:runtime·rt0_go 是 Go 语言运行时的入口点,它负责设置和初始化运行时环境,然后创建 g0 和 m0 来运行程序的主函数。

Go 运行时系统初始化流程

了解完 Go 程序的整体启动流程后,我们重点来分析一下其中的 runtime·checkruntime·argsruntime·osinitruntime·schedinitruntime·newprocruntime·mstart

对了,充分理解 Go 启动流程,可能需要你对 Go 的 GMP 模型有一定的了解。 // TODO

runtime·check

在 Goland IDE 上,我们双击 Shift,全局搜索 runtime·check 会发现找不到函数的实现。

Go 语言的运行时系统大部分是用 Go 自己编写的,但是有一部分,特别是与平台相关的部分,是用汇编语言编写的。在汇编语言中,调用 Go 函数的一种方式是使用 CALL 指令和函数的全名,包括包名和函数名。在这种情况下,runtime·check 就是调用 runtime 包下的 check() 函数。

所以我们需要双击 Shift,搜索 runtine.check,即将 · 换成 .(后面所有函数均是这个道理)。我们会发现 check() 位于 runtime/runtime1.go 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
func check() {
var (
a int8
b uint8
c int16
d uint16
e int32
f uint32
g int64
h uint64
i, i1 float32
j, j1 float64
k unsafe.Pointer
l *uint16
m [4]byte
)
type x1t struct {
x uint8
}
type y1t struct {
x1 x1t
y uint8
}
var x1 x1t
var y1 y1t
// 检查各种类型的变量的大小是否符合预期
if unsafe.Sizeof(a) != 1 {
throw("bad a")
}
...
// 检查指针操作
if unsafe.Sizeof(k) != goarch.PtrSize {
throw("bad k")
}
...
// 检查结构体中字段的偏移量是否符合预期
if unsafe.Offsetof(y1.y) != 1 {
throw("bad offsetof y1.y")
}
// timediv 函数的目的是在 32 位处理器上实现 64 位的除法运算。
// 由于在 32 位处理器上,64 位的除法运算会被转换为 _divv() 函数调用,
// 这可能会超出 nosplit 函数的栈限制,所以需要这个特殊的函数来进行处理。
// //go:nosplit 是一个编译器指令,它告诉编译器不要在这个函数中插入栈分割检查。
// 这意味着这个函数必须在当前的栈帧中运行,不能增加栈的大小。
// 如果这个函数需要更多的栈空间,那么它将会导致栈溢出。
if timediv(12345*1000000000+54321, 1000000000, &e) != 12345 || e != 54321 {
throw("bad timediv")
}
// CAS 操作检查
var z uint32
z = 1
if !atomic.Cas(&z, 1, 2) {
throw("cas1")
}
...
// 检查 atomic 原子操作
m = [4]byte{1, 1, 1, 1}
atomic.Or8(&m[1], 0xf0)
if m[0] != 1 || m[1] != 0xf1 || m[2] != 1 || m[3] != 1 {
throw("atomicor8")
}
// 测试浮点数 NaN(Not a Number)的行为
*(*uint64)(unsafe.Pointer(&j)) = ^uint64(0)
if j == j {
throw("float64nan")
}
if !(j != j) {
throw("float64nan1")
}
// 测试 64 位原子操作
testAtomic64()
// 检查栈大小是否是 2 的 n 次幂
if fixedStack != round2(fixedStack) {
throw("FixedStack is not power-of-2")
}
// 上报编代码的运行时检查中是否有异常
if !checkASM() {
throw("assembly checks failed")
}
}

综上:runtime·check 主要是做一些运行时的检查。

  1. 使用 unsafe.Sizeof 函数检查各种类型的变量的大小是否符合预期。
  2. 使用 unsafe.Offsetof 函数检查结构体中字段的偏移量是否符合预期。
  3. 测试 timediv 函数检查在 32 位机器上进行 64 位除法运算的结果是否符合预期。
  4. 使用 atomic.Cas 函数(Compare and Swap)进行原子比较和交换测试。
  5. 使用 atomic.Or8atomic.And8 函数进行原子位操作测试。
  6. 测试浮点数 NaN(Not a Number)的行为。
  7. 调用 testAtomic64 函数测试 64 位的原子操作。
  8. 检查 fixedStack 栈大小是否是 2 的幂。
  9. 调用 checkASM 函数检查汇编代码检查运行时中是否有异常。

runtime·args

1
2
3
4
5
6
7
8
9
10
11
12
package runtime

var (
argc int32
argv **byte
)

func args(c int32, v **byte) {
argc = c
argv = v
sysargs(c, v)
}

这个函数比较简单,就是将命令行参数拷贝到 runtime 包下的全局变量 argcargv 上。后面在 shcedinit() 函数中会调用 goargs() 来遍历 argv 将参数复制到 slice 上。

1
2
3
4
5
6
7
8
9
func goargs() {
if GOOS == "windows" {
return
}
argslice = make([]string, argc)
for i := int32(0); i < argc; i++ {
argslice[i] = gostringnocopy(argv_index(argv, i))
}
}

runtime·osinit

这里函数主要是初始化操作系统特点的设置,可以看到这里针对不同操作系统都做了实现:

osinit

这里我们以 os_windows.go 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func osinit() {

// 获取 asmstdcall 函数的地址,并将其转换为一个不安全的指针。
// 这通常在需要直接操作内存或进行系统调用的时候使用。
asmstdcallAddr = unsafe.Pointer(abi.FuncPCABI0(asmstdcall))

// 加载一些可选的系统调用。
loadOptionalSyscalls()

// 阻止显示错误对话框。这可能是为了防止在出现错误时打断用户。
preventErrorDialogs()

// 初始化异常处理器,用于处理运行时发生的异常。
initExceptionHandler()

// 初始化高分辨率计时器,用于精确的时间测量。
initHighResTimer()
timeBeginPeriodRetValue = osRelax(false)

// 初始化系统目录
initSysDirectory()

// 启用长路径支持。
// 在 Windows 中,路径的长度通常限制为 260 个字符。启用长路径支持可以突破这个限制。
initLongPathSupport()

// 码获取处理器的数量并将其赋给 ncpu。
ncpu = getproccount()

// 获取内存页的大小并将其赋给 physPageSize,为了后面进行内存管理。
physPageSize = getPageSize()

// 调用 SetProcessPriorityBoost 函数,禁用动态优先级提升。
// 在 Windows 中,动态优先级提升是一种机制,可以根据线程的类型和行为自动调整其优先级。
// 但在 Go 的环境中,所有的线程都是等价的,都可能进行 GUI、IO、计算等各种操作,
// 所以动态优先级提升可能会带来问题,因此这里选择禁用它。
stdcall2(_SetProcessPriorityBoost, currentProcess, 1)
}

runtime·schedinit ★

这个函数就非常重要了,从名字就可以看出来,这是 Go 语言调度器的初始化过程。这个函数位于:runtime/proc.go

我们可以先来看看 schedinit() 的函数注释,这里也透露了 Go 语言程序的启动流程的核心顺序。

1
2
3
4
5
6
7
8
9
// The bootstrap sequence is:				启动流程顺序:
//
// call osinit 1. 调用 osinit
// call schedinit 2. 调用 schedinit
// make & queue new G 3. 创建一个协程 G
// call runtime·mstart 4. 调用 mstart
//
// The new G calls runtime·main. 5. G 执行 runtime.main
func schedinit() {}

接下来我们详细来看看 schedinit() 都做了些什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
func schedinit() {

// 初始化各种锁,其中 lockRankXXX 指定锁的级别。
lockInit(&sched.lock, lockRankSched)
lockInit(&sched.sysmonlock, lockRankSysmon)
lockInit(&sched.deferlock, lockRankDefer)
lockInit(&sched.sudoglock, lockRankSudog)
lockInit(&deadlock, lockRankDeadlock)
lockInit(&paniclk, lockRankPanic)
lockInit(&allglock, lockRankAllg)
lockInit(&allpLock, lockRankAllp)
lockInit(&reflectOffs.lock, lockRankReflectOffs)
lockInit(&finlock, lockRankFin)
lockInit(&cpuprof.lock, lockRankCpuprof)
traceLockInit()
lockInit(&memstats.heapStats.noPLock, lockRankLeafRank)

// 如果启用了竞态检测,则初始化竞态检测器,
// 即我们使用 -race 的时候会执行这里。
gp := getg()
if raceenabled {
gp.racectx, raceprocctx0 = raceinit()
}

// 限制 M 的数量,即线程的数量。
// maxmcount int32 // maximum number of m's allowed (or die)
sched.maxmcount = 10000

// 将调度器设置为初始暂停状态,在必要的初始化完成之前不调度任何协程。
worldStopped()

// 进行一系列的系统初始化(内存管理、CPU 设置、栈、算法等)
moduledataverify()
stackinit()
mallocinit()
godebug := getGodebugEarly()
initPageTrace(godebug) // must run after mallocinit but before anything allocates
cpuinit(godebug) // must run before alginit
alginit() // maps, hash, fastrand must not be used before this call
fastrandinit() // must run before mcommoninit
mcommoninit(gp.m, -1)
modulesinit() // provides activeModules
typelinksinit() // uses maps, activeModules
itabsinit() // uses activeModules
stkobjinit() // must run before GC starts

// 设置和保存当前 M 的信号掩码
sigsave(&gp.m.sigmask)
initSigmask = gp.m.sigmask

// 解析程序参数和环境变量
goargs()
goenvs()
secure()
parsedebugvars()

// 初始化垃圾回收器
gcinit()

// 如果设置了 disableMemoryProfiling,即禁用内存分析,
// 则将 MemProfileRate 置为 0,关闭内存分析。
if disableMemoryProfiling {
MemProfileRate = 0
}

// 锁定调度器,处理环境变量 GOMAXPROCS,这是开发者可以设置的允许的最多的 P 的数量。
lock(&sched.lock)
sched.lastpoll.Store(nanotime())
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}
unlock(&sched.lock)

// 将调度器设置为开始状态。
worldStarted()

// 确保构建版本和模块信息被保留在最终的二进制文件中。
if buildVersion == "" {
buildVersion = "unknown"
}
if len(modinfo) == 1 {
modinfo = ""
}
}

总结:schedinit 是 Go 语言运行时中的一个函数,负责初始化调度器及其相关组件,如锁、信号掩码、内存分配、以及其他系统级别的设置,确保并发执行环境的正确配置和高效运作。

具体过程如下:

  1. 锁初始化:
    • 函数开始时,通过 lockInit 调用初始化了多个锁。在 Go 的调度器中,锁用于保护共享资源和调度数据结构,确保在多个线程或协程中的安全访问。每个锁都有一个特定的级别,这有助于防止死锁。
  2. 竞态检测器初始化:
    • 如果启用了竞态检测 (raceenabled),则初始化竞态上下文。这对于在开发阶段检测和避免竞态条件非常重要。
  3. 调度器设置:
    • sched.maxmcount = 10000 设置调度器可以管理的最大线程(M)数目,这对于控制资源使用和性能调优很重要。
    • worldStopped() 将调度器设置为初始暂停状态,在必要的初始化完成之前不调度任何协程。
  4. 系统初始化:
    • 接下来调用一系列函数(如 moduledataverify, mallocinit, cpuinit, alginit 等)来初始化内存管理、CPU 设置、算法等,这些都是调度器正常工作的基础。
  5. 环境和调试变量设置:
    • 解析程序参数、环境变量、安全设置和调试变量。
  6. 垃圾收集器初始化:
    • gcinit() 初始化垃圾收集器,这是 Go 运行时的关键组成部分,负责自动内存管理。
  7. 内存分析设置:
    • 根据 disableMemoryProfiling 标志决定是否关闭内存分析功能。
  8. 处理器数量设置和调度器锁:
    • 锁定调度器来安全地基于环境变量 GOMAXPROCS 设置处理器(procs)数量。
    • 使用 procresize 函数根据处理器数量调整调度器的内部结构。
  9. 最终步骤和错误检查:
    • 调用 worldStarted() 表示调度器已准备好开始调度协程。
    • 检查和设置构建版本和模块信息,保证这些信息在最终的二进制文件中。

这里有几个地方比较有趣,我们来做一下简单的了解。(可跳过)

初始化锁 lockInit(mutex, rank)

我们知道 lockInit(mutex,rank) 是用来初始化锁的,第 2 个参数 rank 便是锁的等级。如果这个时候你链接到 lcokInit 实现的地方,你会发现默认会跳到 lockrank_off.go,而且你会发现,它的实现是空的:

1
2
3
4
5
6
//go:build !goexperiment.staticlockranking

package runtime

func lockInit(l *mutex, rank lockRank) {
}

其实 lockInit 还有另外一个实现,在 lockrank_on.go 文件中:

1
2
3
4
5
6
7
8
9
//go:build goexperiment.staticlockranking

package runtime

const staticLockRanking = true

func getLockRank(l *mutex) lockRank {
return l.rank
}

这什么意思呢?通过文件名称我们其实就可以猜到了,lockrank_off.go 是提供了无锁级别的锁,而 lockrank_on.go 是提供了有锁级别的锁。至于应该采用哪一个,是通过 go build 中的 goexperiment.staticlockranking 参数来控制的。

这里涉及一个概念,叫做锁排序(Lock Ranking):

  • 锁排序是一种用于避免死锁的技术。在这种机制中,每个锁都被赋予一个等级(或称为 “rank”),并且有规则确保锁的获取遵循这些等级的顺序。
  • 通常,这意味着一个线程在获取等级较低的锁之前,必须先释放所有等级较高的锁。这样可以防止死锁,因为它避免了循环等待条件的发生。
  • Go 语言中锁的等级和顺序定义在 lockrank.go 文件中。

锁排序的作用:

  • 在 Go 的并发模型中,锁是同步共享资源访问的重要机制。lockInit 函数在运行时初始化锁,为其分配等级,有助于维护程序的稳定性和性能。
  • 锁排序功能的开启或关闭取决于是否需要额外的死锁检测。在开发和调试阶段,开启锁排序可以帮助发现死锁问题。然而,它可能引入额外的性能开销,因此在生产环境中可能会被关闭。

最后我们来看一下 schedinit() 都初始化了哪些锁:

Lock Description
sched.lock 初始化调度器的主锁。这个锁用于控制对调度器的访问,保证调度过程的正确性。
sched.sysmonlock 系统监控锁,用于保护系统监控相关的数据结构。
sched.deferlock 用于控制延迟执行函数列表的锁。
sched.sudoglock sudog 是 Go 中表示等待通信的 goroutine 的结构。这个锁保护与 sudog 相关的操作。
deadlock 可能用于检测或防止死锁的锁。
paniclk 在处理 panic 时使用的锁。
allglock 用于控制对所有 goroutine 列表的访问。
allpLock 控制对所有处理器(P)的访问。
reflectOffs.lock 用于反射操作的锁。
finlock 管理终结器列表的锁。
cpuprof.lock 用于 CPU 分析数据的锁。
traceLockInit() 专门用于追踪系统的锁初始化函数。
memstats.heapStats.noPLock 这是一个特殊的锁,被标记为 lockRankLeafRank,意味着它应该是锁层级中的最末端(leaf)。这样的锁应该只在非常短的关键部分中使用,以避免成为死锁的源头。

信号掩码 initSigmask

这两行代码是在搞啥呢?

1
2
sigsave(&gp.m.sigmask)
initSigmask = gp.m.sigmask
  • sigmask 的中文意思是 信号掩码

先看一下源码中 initSigmast 的注释:

1
2
// Value to use for signal mask for newly created M's.
var initSigmask sigset
  • initSigmask 是一个变量,存储着用于新创建的 M(Machine,即操作系统线程)的初始信号掩码。

什么是信号掩码:

  • 信号掩码是操作系统中用于控制信号传递给进程或线程的一种机制。它允许进程或线程指定哪些信号可以被阻塞(暂时忽略)或允许。在多线程环境中,这个机制尤其重要,因为它帮助确保线程安全地处理信号。

信号掩码的作用:

  • 信号掩码定义了一组信号,这些信号在特定时间内不会传递给进程或线程,即使这些信号发生了也会被系统挂起。这允许进程或线程在一个稳定的状态下运行,不被特定信号中断。
  • 这种机制对于处理那些可能在关键操作期间导致不稳定状态的信号特别重要。

信号掩码的重要性:

  • 在多线程程序中,不同的线程可能需要响应不同的信号或以不同方式处理相同的信号。通过为每个线程设置适当的信号掩码,可以确保线程只处理对它们来说重要的信号。
  • 这有助于防止线程在执行关键代码时被不相关的信号打断。

sigsave(&gp.m.sigmask)

  • sigsave(&gp.m.sigmask) 这个调用是在保存当前 M 的信号掩码。gp 指的是当前的 goroutine,gp.m 是该 goroutine 正在运行的 M(操作系统线程)。
  • sigsave 函数的作用是将 gp.m 的当前信号掩码保存到提供的地址(在这里是 &gp.m.sigmask)。这对于恢复线程的信号掩码到一个已知状态是非常有用的。

initSigmask = gp.m.sigmask

  • 这一行将 gp.m 的信号掩码赋值给 initSigmask。这意味着 initSigmask 现在保存了当前 M 的信号掩码,这个掩码将被用作新创建的 M 的初始信号掩码。
  • 这是一个重要的步骤,因为它确保了所有新创建的 M 都将具有与当前 M 相同的信号处理行为。
  • 这意味着所有新线程都会以一致的信号掩码启动,这有助于避免由于不同线程处理信号的不一致性导致的问题。

总体来说,Go 语言在其运行时中这样处理信号掩码,是为了确保在并发执行和线程调度中能够安全、一致地处理信号,这对于维护高效和稳定的运行时环境至关重要。

初始化垃圾回收器 gcinit()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
func gcinit() {
// 检查 workbuf 结构体的大小是否等于预期的 _WorkbufSize。
// 如果不是,抛出异常。这是为了确保 workbuf 的大小是最优的,
// workbuf 用于垃圾回收过程中的内部工作。
if unsafe.Sizeof(workbuf{}) != _WorkbufSize {
throw("size of Workbuf is suboptimal")
}
// 第一个垃圾回收周期不进行扫描操作。
// 在 Go 的垃圾回收过程中,扫描是回收前清理内存的重要步骤。
sweep.active.state.Store(sweepDrainedMask)

// 使用环境变量 GOGC 和 GOMEMLIMIT 来设置初始的垃圾回收百分比和内存限制。
gcController.init(readGOGC(), readGOMEMLIMIT())

// 初始化用于控制垃圾回收工作流程的信号量。
// 这些信号量用于同步垃圾回收过程中的不同阶段。
work.startSema = 1
work.markDoneSema = 1

// 初始化了用于垃圾回收过程中的各种锁。
// 这些锁用于保护垃圾回收相关数据结构的并发访问,确保垃圾回收过程的线程安全。
lockInit(&work.sweepWaiters.lock, lockRankSweepWaiters)
lockInit(&work.assistQueue.lock, lockRankAssistQueue)
lockInit(&work.wbufSpans.lock, lockRankWbufSpans)
}

func (c *gcControllerState) init(gcPercent int32, memoryLimit int64) {
// 设置 heapMinimum 为默认的最小堆大小。
// 这是垃圾回收器考虑启动新回收周期前的最小堆内存大小。
c.heapMinimum = defaultHeapMinimum

// 将 triggered 设置为 uint64 的最大值。
// 这个字段用于表示触发垃圾回收的内存阈值,
// 这里的设置意味着在初始状态下不会自动触发垃圾回收
c.triggered = ^uint64(0)

// 设置垃圾回收的百分比阈值。
// gcPercent 参数表示触发垃圾回收的内存增长百分比。
// 这个设置控制了堆内存增长到多少百分比时会触发垃圾回收。
c.setGCPercent(gcPercent)

// 设置内存限制。
// memoryLimit 参数可能表示堆内存的最大限制,
// 用于控制垃圾回收器在内存使用方面的行为。
c.setMemoryLimit(memoryLimit)

// 提交垃圾回收控制器的当前设置,并指示第一次垃圾回收周期没有扫描(sweep)阶段。
// 在 Go 的垃圾回收中,扫描是回收周期的一部分,这里指明在第一次垃圾回收时跳过扫描阶段。
c.commit(true)
}

runtime·newproc ★

初始化完调度器后,就进入到创建 g0 的阶段了,我们需要一个协程来运行程序的入口:runtime.main

newproc() 的作用如注释所说:创建一个新的 goroutine 来执行 fn,并将它放入等待运行的 g 队列中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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() // 获取当前程序计数器
systemstack(func() { // 在系统栈上执行新 goroutine 的创建
newg := newproc1(fn, gp, pc) // 创建新 goroutine

pp := getg().m.p.ptr() // 获取当前 M 绑定的 P
runqput(pp, newg, true) // 将新创建的 goroutine 放入 P 的本地队列中

if mainStarted {
wakep() // 如果主程序已经启动,则唤醒或启动一个 M,以确保新的 goroutine 有机会被执行
}
})
}

重点来看一下 newproc1()runqput()

创建协程 newproc1()

这段代码的主要作用是创建一个新的 goroutine 并设置其初始状态,以便它可以被调度器安排运行。它处理了从分配 goroutine 的内存到设置其栈空间和调度信息等一系列步骤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// 创建一个新的 goroutine,状态为 _Grunnable,从函数 fn 开始执行。
// callerpc 是创建此 goroutine 的 go 语句的地址。
// 调用者负责将新的 g 添加到调度器中。
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
if fn == nil {
fatal("go of nil func value") // 如果 fn 是 nil,抛出致命错误
}

mp := acquirem() // 禁用抢占,因为我们在局部变量中持有 M 和 P
pp := mp.p.ptr()
newg := gfget(pp) // 尝试从 P 的空闲列表中获取一个 g
if newg == nil {
newg = malg(stackMin) // 如果没有空闲的 g,创建一个新的
casgstatus(newg, _Gidle, _Gdead) // 将 g 的状态从 _Gidle 改为 _Gdead
allgadd(newg) // 将新的 g 添加到所有 goroutine 的列表中
}
if newg.stack.hi == 0 {
throw("newproc1: newg missing stack") // 如果新的 g 没有栈,抛出异常
}

if readgstatus(newg) != _Gdead {
throw("newproc1: new g is not Gdead") // 确保新的 g 的状态为 _Gdead
}

totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize) // 计算额外的栈空间大小
totalSize = alignUp(totalSize, sys.StackAlign) // 栈空间对齐
sp := newg.stack.hi - totalSize // 设置栈指针
spArg := sp
if usesLR {
*(*uintptr)(unsafe.Pointer(sp)) = 0 // 针对 LR 架构,设置调用者的 LR
prepGoExitFrame(sp)
spArg += sys.MinFrameSize
}

memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched)) // 清除调度器的内存
newg.sched.sp = sp // 设置调度器的栈指针
newg.stktopsp = sp // 设置栈顶指针
// 设置调度器的程序计数器,+PCQuantum 使得前一个指令在同一函数中
newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum
newg.sched.g = guintptr(unsafe.Pointer(newg)) // 设置调度器的 g 指针
gostartcallfn(&newg.sched, fn) // 启动新的 g 执行函数 fn
newg.parentGoid = callergp.goid // 设置新 g 的父 goroutine ID
newg.gopc = callerpc // 设置新 g 的创建位置
newg.ancestors = saveAncestors(callergp) // 保存祖先信息
newg.startpc = fn.fn // 设置新 g 的起始函数地址
if isSystemGoroutine(newg, false) {
sched.ngsys.Add(1) // 如果是系统 goroutine,增加计数
} else {
if mp.curg != nil {
newg.labels = mp.curg.labels // 只有用户 goroutines 继承 pprof 标签
}
if goroutineProfile.active {
newg.goroutineProfiled.Store(goroutineProfileSatisfied) // 标记不需要纳入 goroutine 分析
}
}
newg.trackingSeq = uint8(fastrand()) // 设置追踪序列号
if newg.trackingSeq%gTrackingPeriod == 0 {
newg.tracking = true // 是否启用追踪
}
casgstatus(newg, _Gdead, _Grunnable) // 将新 g 的状态从 _Gdead 改为 _Grunnable
gcController.addScannableStack(pp, int64(newg.stack.hi-newg.stack.lo)) // 将新 g 的栈添加到可扫描栈列表

if pp.goidcache == pp.goidcacheend {
pp.goidcache = sched.goidgen.Add(_GoidCacheBatch) // 分配新的 goroutine ID
pp.goidcache -= _GoidCacheBatch - 1
pp.goidcacheend = pp.goidcache + _GoidCacheBatch
}
newg.goid = pp.goidcache // 设置新 g 的 ID
pp.goidcache++
if raceenabled {
newg.racectx = racegostart(callerpc) // 设置竞态检测上下文
newg.raceignore = 0
if newg.labels != nil {
racereleasemergeg(newg, unsafe.Pointer(&labelSync)) // 同步竞态检测和信号处理
}
}
if traceEnabled() {
traceGoCreate(newg, newg.startpc) // 记录追踪信息
}
releasem(mp) // 释放当前 M

return newg // 返回新创建的 goroutine
}
newproc1() 函数概述

有几个地方比较有趣,我们可以来研究一下。

获取 g

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// The minimum size of stack used by Go code
stackMin = 2048

func newproc1() {
...
newg := gfget(pp) // 尝试从 P 的空闲列表中获取一个 g
if newg == nil {
newg = malg(stackMin) // 如果没有空闲的 g,创建一个新的
casgstatus(newg, _Gidle, _Gdead) // 将 g 的状态从 _Gidle 改为 _Gdead
allgadd(newg) // 将新的 g 添加到所有 goroutine 的列表中
}
...
}

这里可以看出,Go 语言每个新创建的协程分配的默认大小就是 stackMin,即 2KB

其实 statck.go 还定义了另外一个字段,即栈最大为 2GB,所以我们可以知道,Go 协程栈大小 [2KB, 2GB]

1
var maxstacksize uintptr = 1 << 20 // enough until runtime.main sets it for real

另外我们可以看出来,Go 语言会尽可能重用现有的空闲 goroutine,以减少内存分配的开销,提供创建新 goroutine 的效率。

重用的具体逻辑在 gfget(pp) 中,这个函数的作用是从与当前 M 绑定的 P 的空闲列表中获取一个空闲的 g,如果没有,则尝试从全局空闲列表中获取 g。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// Get from gfree list.
// If local list is empty, grab a batch from global list.
func gfget(pp *p) *g {
retry:
// 如果当前 P 的空闲队列为空,并且全局空闲队列中有可用的 goroutine,则进行下列操作。
if pp.gFree.empty() && (!sched.gFree.stack.empty() || !sched.gFree.noStack.empty()) {
// 枷锁全局空闲列表
lock(&sched.gFree.lock)
// 将最多 32 个空闲的 g 从全局列表中移动到当前 P 的空闲列表
for pp.gFree.n < 32 {
// 优先选择有栈的 g
gp := sched.gFree.stack.pop()
if gp == nil {
// 实在没有栈,也可以接受
gp = sched.gFree.noStack.pop()
if gp == nil {
break
}
}
sched.gFree.n--
pp.gFree.push(gp)
pp.gFree.n++
}
unlock(&sched.gFree.lock)
// 一直尝试,知道 P 有空闲 g,或者全局列表也没有空闲 g 了,就退出 for 循环,进行下面的操作。
goto retry
}

// 从 P 的空闲列表中取出一个 g
gp := pp.gFree.pop()
if gp == nil {
return nil
}
pp.gFree.n--

// 检查获取到的 g 是否有一个有效的栈,如果栈不符合预期的大小,则释放旧栈
if gp.stack.lo != 0 && gp.stack.hi-gp.stack.lo != uintptr(startingStackSize) {
systemstack(func() {
stackfree(gp.stack)
gp.stack.lo = 0
gp.stack.hi = 0
gp.stackguard0 = 0
})
}
// 如果 g 没有有效的栈,或者刚刚被释放了,则分配新栈给 g
if gp.stack.lo == 0 {
systemstack(func() {
gp.stack = stackalloc(startingStackSize)
})
gp.stackguard0 = gp.stack.lo + stackGuard
} else {
if raceenabled {
racemalloc(unsafe.Pointer(gp.stack.lo), gp.stack.hi-gp.stack.lo)
}
if msanenabled {
msanmalloc(unsafe.Pointer(gp.stack.lo), gp.stack.hi-gp.stack.lo)
}
if asanenabled {
asanunpoison(unsafe.Pointer(gp.stack.lo), gp.stack.hi-gp.stack.lo)
}
}
return gp
}

其中 startingStackSize 表示新创建的 goroutine 开始时的栈大小。它被初始化为 fixedStack 的值。startingStackSize 在每次垃圾回收(GC)后可能会更新,以反映在 GC 过程中扫描的栈的平均大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var startingStackSize uint32 = fixedStack
func gcComputeStartingStackSize() {
...
// 求出栈平均大小
avg := scannedStackSize/scannedStacks + stackGuard

if avg > uint64(maxstacksize) {
avg = uint64(maxstacksize)
}
if avg < fixedStack {
avg = fixedStack
}
// 更新 startingStackSize
startingStackSize = uint32(round2(int32(avg)))
}

初始化协程栈

1
2
3
4
5
6
7
8
9
totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize) // 计算额外的栈空间大小
totalSize = alignUp(totalSize, sys.StackAlign) // 栈空间对齐
sp := newg.stack.hi - totalSize // 设置栈指针
spArg := sp
if usesLR {
*(*uintptr)(unsafe.Pointer(sp)) = 0 // 针对 LR 架构,设置调用者的 LR
prepGoExitFrame(sp)
spArg += sys.MinFrameSize
}

1. 计算额外的栈空间大小:

totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize) 这行代码计算新 goroutine 需要的额外栈空间大小。

4*goarch.PtrSize 是为了留出足够的空间来存储函数调用过程中的一些额外信息(例如返回地址、寄存器保存等)。

sys.MinFrameSize :是系统为每个栈帧保留的最小空间,用于存储一些特定于架构的信息。

1
2
3
4
5
6
// MinFrameSize is the size of the system-reserved words at the bottom
// of a frame (just above the architectural stack pointer).
// It is zero on x86 and PtrSize on most non-x86 (LR-based) systems.
// On PowerPC it is larger, to cover three more reserved words:
// the compiler word, the link editor word, and the TOC save word.
const MinFrameSize = goarch.MinFrameSize

goarch.PtrSize:指针大小。

1
2
3
// PtrSize is the size of a pointer in bytes - unsafe.Sizeof(uintptr(0)) but as an ideal constant.
// It is also the size of the machine's native word size (that is, 4 on 32-bit systems, 8 on 64-bit).
const PtrSize = 4 << (^uintptr(0) >> 63)

2. 栈空间对齐:

totalSize = alignUp(totalSize, sys.StackAlign): 根据系统的栈对齐要求调整 totalSize 的大小。栈对齐是为了确保栈上的数据按照硬件要求的边界对齐,这通常是为了提高访问效率或满足特定的硬件要求。

3. 设置栈指针 (sp):

sp := newg.stack.hi - totalSize: 计算新 goroutine 的栈顶指针。newg.stack.hi 是分配给这个 goroutine 的栈的高地址(栈顶),从这里向下分配空间。通过从栈顶地址减去计算出的 totalSize,设置新的栈顶位置。

4. 处理链接寄存器(LR)架构:

在某些架构上(如 ARM、PowerPC),函数调用的返回地址不是存储在栈上,而是存储在一个名为链接寄存器(LR)的特殊寄存器中。这几行代码检查是否在这种架构上运行 (usesLR)。

  • 如果是,则在栈上的适当位置存储一个 0 值作为返回地址,并调用 prepGoExitFrame 来准备 goroutine 退出时的栈帧。这是为了模拟在非 LR 架构上的栈帧结构。
  • spArg 是一个辅助变量,用于记录参数传递时应该使用的栈地址。在 LR 架构上,它需要根据 sys.MinFrameSize 进行调整,以保证函数参数的正确位置。

放入队列 runqput()

runqput() 负责将 goroutine (gp) 放入到本地可运行队列或全局队列中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// runqput 尝试将 g 放入当前的执行队列中
// 如果 next=false,则将 g 放在队列末尾,
// 如果 next=true,则将 g 放在 pp.runnext,即下一个要执行的 goroutine。
// 如果本地队列满了,则加入到全局队列。
func runqput(pp *p, gp *g, next bool) {
// 如果启用了随机调度器 (randomizeScheduler),
// 并且调用者指示将 goroutine 放入 runnext 位置 (next 为 true),
// 则有 50% 的概率将 next 设置为 false,以随机地将 goroutine 放入队列尾部。
if randomizeScheduler && next && fastrandn(2) == 0 {
next = false
}

if next {
retryNext:
// 如果为 next,则尝试将 p 放入 pp.runnext 插槽
oldnext := pp.runnext
if !pp.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
goto retryNext
}
// 如果这个槽之前没有被占用,则直接返回
if oldnext == 0 {
return
}
// 如果这个槽之前已经被占用了,则剔除旧 goroutine,
// 然后进行下面的逻辑,即将其放入常规的运行队列中
gp = oldnext.ptr()
}

retry:
h := atomic.LoadAcq(&pp.runqhead) // 取出队列头部
t := pp.runqtail // 取出队列尾部
// 如果还没满,则将 gp 放入本地队列中(可能是新 g,也可能是之前在 runnext 的 g
if t-h < uint32(len(pp.runq)) {
pp.runq[t%uint32(len(pp.runq))].set(gp)
atomic.StoreRel(&pp.runqtail, t+1) // store-release, makes the item available for consumption
return
}
// 如果本地队列满了,则尝试将其放入全局队列中
if runqputslow(pp, gp, h, t) {
return
}
goto retry
}

其中 runqputslow() 不仅会尝试将 gp 放入全局队列中,还会尝试将本地队列的部分 g 放入全局队列中,因为这个时候本地队列已经满了,放入全局队列中就有机会被其他 P 所调度,减少饥饿。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Put g and a batch of work from local runnable queue on global queue.
// Executed only by the owner P.
func runqputslow(pp *p, gp *g, h, t uint32) bool {

// 初始化一个数组 batch,大小为本地队列的一半,
// 它用来存储将要移动到全局队列的 goroutine。
var batch [len(pp.runq)/2 + 1]*g

n := t - h
n = n / 2
// 只有本地队列满才这么操作
if n != uint32(len(pp.runq)/2) {
throw("runqputslow: queue is not full")
}
// 将本地队列一半的 g 复制到 batch 中
for i := uint32(0); i < n; i++ {
batch[i] = pp.runq[(h+i)%uint32(len(pp.runq))].ptr()
}
// CAS 更新本地队列头指针,如果失败,则返回
if !atomic.CasRel(&pp.runqhead, h, h+n) { // cas-release, commits consume
return false
}
// 将当前要调度的 gp 放入 batch 的末尾
batch[n] = gp

// 如果启动了随机调度器,则随机化 batch 数组
if randomizeScheduler {
for i := uint32(1); i <= n; i++ {
j := fastrandn(i + 1)
batch[i], batch[j] = batch[j], batch[i]
}
}

// 链接 goroutine,以便它们可以作为一个连续的队列被处理
for i := uint32(0); i < n; i++ {
batch[i].schedlink.set(batch[i+1])
}
var q gQueue
q.head.set(batch[0])
q.tail.set(batch[n])

// 将 batch 放入全局队列中
lock(&sched.lock)
globrunqputbatch(&q, int32(n+1))
unlock(&sched.lock)
return true
}
runqput() 函数概述

总结

到这里我们可以总结,runtime·newproc 的核心功能就是初始化一个新的 goroutine,并将其放入队列中进行调度。其中

  • newproc1() 会新建或复用空闲的 goroutine,然后初始化其栈空间和调度信息;
  • runqput() 会优先将 g 放入本地队列中调度,如果本地队列满了,会连带本地队列中一半的 goroutine 一起转移到全局队列中调度。

runtime·mstart

调度器 m0 和主协程 g0 都初始化完毕了,这个时候就可以启动调度器来调度协程工作了。

我们找到 mstart() 函数的声明,位于:proc.go

1
2
3
// mstart is the entry-point for new Ms.
// It is written in assembly, uses ABI0, is marked TOPFRAME, and calls mstart0.
func mstart()

可以看到,这里 mstart() 是没用函数体的,通过注释我们可以知道这个函数的实现部分是用汇编实现的,Go 编译器会在编译的时候往这个函数里面插入相关指令。另外注释也告诉我们,这里最终其实就是调用 mstart0() 函数。我们找到相关的汇编代码,果然是如此:

1
2
3
TEXT runtime·mstart(SB),NOSPLIT|TOPFRAME,$0
CALL runtime·mstart0(SB)
RET // not reached

mstart0() 就在 mstart() 的下面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func mstart0() {
gp := getg()
osStack := gp.stack.lo == 0
if osStack {
// Initialize stack bounds from system stack.
// Cgo may have left stack size in stack.hi.
// minit may update the stack bounds.
size := gp.stack.hi
if size == 0 {
size = 8192 * sys.StackGuardMultiplier
}
gp.stack.hi = uintptr(noescape(unsafe.Pointer(&size)))
gp.stack.lo = gp.stack.hi - size + 1024
}
// Initialize stack guard so that we can start calling regular
// Go code.
gp.stackguard0 = gp.stack.lo + stackGuard
// This is the g0, so we can also call go:systemstack
// functions, which check stackguard1.
gp.stackguard1 = gp.stackguard0
mstart1()
// Exit this thread.
if mStackIsSystemAllocated() {
// Windows, Solaris, illumos, Darwin, AIX and Plan 9 always system-allocate
// the stack, but put it in gp.stack before mstart,
// so the logic above hasn't set osStack yet.
osStack = true
}
mexit(osStack)
}

简单过一下 mstart0() 后,我们会发现其实 mstart0() 也不是关键,关键是 mstart1()

我们先对 mstart0() 做一个简单的总结:它是 Go 语言运行时中新 M(操作系统线程)的入口点。这个函数负责初始化新线程的栈和一些其他设置,然后调用 mstart1 来继续线程的初始化过程。

我们继续来看 mstart1(),它用于进一步设置新线程并最终将控制权交给调度器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
func mstart1() {
// 获取当前协程
gp := getg()
// 只有 g0 协程可以执行 mstart1(),即启动 m0。
// 每一个 M 都有一个特殊的 goroutine,其被称为 g0,它用于执行系统级任务。
if gp != gp.m.g0 {
throw("bad runtime·mstart")
}

// 设置 gp.sched 调度信息,以便 goroutine 能够在未来被正确调度。
gp.sched.g = guintptr(unsafe.Pointer(gp)) // goroutine 指针
gp.sched.pc = getcallerpc() // 程序计数器
gp.sched.sp = getcallersp() // 栈指针

// 初始化于汇编相关的设置。
asminit()
// 初始化当前 M 的线程局部存储和其他线程相关的数据。
minit()

// 如果是 m0,则安装信号处理器
if gp.m == &m0 {
mstartm0()
}

// 如果 M 启动时配置了函数,则调用它
if fn := gp.m.mstartfn; fn != nil {
fn()
}

// 如果当前现场不是 m0(主线程),则获取一个 P,准备开始执行 goroutine
if gp.m != &m0 {
acquirep(gp.m.nextp.ptr())
gp.m.nextp = 0
}
// 调用 schedule() 将控制权交给调度器,开始执行 goroutine
schedule()
}

func mstartm0() {
if (iscgo || GOOS == "windows") && !cgoHasExtraM {
cgoHasExtraM = true
newextram()
}
// 安装信号处理器
initsig(false)
}

其中 schedule() 是调度器的具体调度过程,这部分会在 GMP 篇章进行展开(TODO 😁)。

注意这里:

1
2
3
4
// 如果 M 启动时配置了函数,则调用它
if fn := gp.m.mstartfn; fn != nil {
fn()
}

前面我们提到 runtime·newproc 的时候,获取并设置了 runtime.main 的函数地址:

1
2
3
4
5
6
7
8
9
// 取 runtime·mainPC 的地址,这其实就是 runtime 包下的 main() 方法。
// 它是 Go 语言程序的真正入口,而不是 main.main()。
MOVQ $runtime·mainPC(SB), AX
PUSHQ AX
// 创建一个新的 goroutine 来运行程序的主函数。
// 这里还没有正在的运行,因为调度器还没有启动,
// 只是将 runtime.main 放进 goroutine 的 queue 中等待执行。
CALL runtime·newproc(SB)
POPQ AX

所以这里其实就是调用 runtime.main(),到这里,我们终于开始执行程序的主函数了。

runtime.main ★

终于我们到了 runtime.main 这个 Go 语言世界中 “真正” 的主函数了,它位于:proc.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// The main goroutine.
func main() {

// 获取当前的 M
mp := getg().m

mp.g0.racectx = 0

// 限制栈大小的上限,64 位系统为 1G,32 位系统为 250M
if goarch.PtrSize == 8 {
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
}

// 这里就将栈的上限提升 2 倍,用于避免在分配过大的栈时崩溃。
// 所以其实 64 位系统最大栈 2G,32 位系统最大栈 500M。
maxstackceiling = 2 * maxstacksize

// 将 mainStarted 置为 true,允许 newproc 启动新的 M。
mainStarted = true

// WebAssemby 上暂时没有线程和系统监视器,所以这里过滤掉。
if GOARCH != "wasm" {
// 其他架构就启动系统监视器。
systemstack(func() {
newm(sysmon, nil, -1)
})
}

// 在初始化期间将 g0 锁定在 m0 上。
lockOSThread()

// 只有 m0 可以运行 runtime.main
if mp != &m0 {
throw("runtime.main not on m0")
}

// 记录 runtime 的开始时间,需要在 doInit() 之前,因为这样才把 init 也追踪上。
runtimeInitTime = nanotime()
if runtimeInitTime == 0 {
throw("nanotime returning zero")
}

// 初始化 trace
if debug.inittrace != 0 {
inittrace.id = getg().goid
inittrace.active = true
}

// 执行 runtime 的 init() 方法
doInit(runtime_inittasks) // Must be before defer.

// defer 解锁,以便在初始化期间调用 runtime.Goexit 时也能解锁。
needUnlock := true
defer func() {
if needUnlock {
unlockOSThread()
}
}()

// 启动垃圾回收期
gcenable()

// 监听初始化完成的信号
main_init_done = make(chan bool)

// 如果使用 cgo,则进行相关的初始化
if iscgo {
...
cgocall(_cgo_notify_runtime_init_done, nil)
}

// 执行所有模块的 init()
for _, m := range activeModules() {
doInit(m.inittasks)
}

// 初始化任务都完成后,则禁用初始化 trace。
inittrace.active = false

// 关闭初始化完成的信号通道
close(main_init_done)

// 解锁 m0 线程
needUnlock = false
unlockOSThread()

// 以 -buildmode=(c-archive|c-shared) 方式进行构建程序的话,则不执行 main.main
if isarchive || islibrary {
// A program compiled with -buildmode=c-archive or c-shared
// has a main, but it is not executed.
return
}

// 执行 main.main() 函数,也就是我们自己写的 main()
fn := main_main
fn() // 如果我们启动了一个 server 服务,这里就会被阻塞住,直到我们的 main 返回。

// 静态检测输出
if raceenabled {
runExitHooks(0) // run hooks now, since racefini does not return
racefini()
}

// 处理在 main 返回时同时存在的其他 goroutine 的 panic
if runningPanicDefers.Load() != 0 {
for c := 0; c < 1000; c++ {
// 执行 defer
if runningPanicDefers.Load() == 0 {
break
}
Gosched()
}
}
// 阻塞 g0 的执行,直到所有的 panic 都处理完毕
if panicking.Load() != 0 {
gopark(nil, nil, waitReasonPanicWait, traceBlockForever, 1)
}

// 执行 hook 退出函数
runExitHooks(0)
// 退出程序,0 表示正常退出
exit(0)
// 理论上 exit(0) 应该退出程序的,
// 如果还没退出,使用 nil 指针强行赋值,引发崩溃,强行退出程序。
for {
var x *int32
*x = 0
}
}

总的来说,runtime.main() 干这么几件事:

  1. 首先进行一些基本的设置和检查,包括设置栈大小限制和锁定主 goroutine 到主 OS 线程。
  2. 然后,函数执行一系列初始化操作,包括启动垃圾回收器、处理 CGo 交互、执行包的 init()。
  3. 在完成所有 init() 后,函数调用用户定义的 main.main 函数。
  4. 最后,函数处理程序退出,包括执行 defer、等待 panic 处理完成,并正式退出程序。

所以这里我们就知道了为什么 init() 会在 main.main() 之前被执行,而且如果一个 package 在整个程序路径都没有被 import 的时候,init() 是不会被执行的,就是因为 runtime.main() 只处理了 activeModules()initTasks()

到这里,还有个遗留问题,开发时我们需要关注的 init()main.main()可以讨论过了,那全局变量的初始化是在哪里做的呢?

在 Go语言的编译过程中,全局变量的初始化主要发生在链接阶段。编译器首先编译每个包,生成对象文件。然后在链接阶段,编译器或链接器将这些对象文件合并成一个可执行文件。在这个过程中,编译器或链接器负责生成初始化全局变量的代码,并安排这些代码在程序启动时执行。

这些初始化代码通常嵌入在程序的启动序列中,确保在执行任何包级init 函数或用户定义的 main函数之前,所有全局变量已经被初始化。由于这些操作是编译器在内部执行的,它们不会直接显示在源代码或运行时代码中。

runtime.main 函数概述

至此,我们就分析完 Go 语言程序的整个启动过程了。具体的启动流程总结,可以回到开头的 “结论先行” 查看。

参考


Go1.21.0 程序启动过程
https://hedon.top/2023/12/07/go-start/
Author
Hedon Wang
Posted on
2023-12-07
Licensed under