Go 中的栈

之前在尝试阅读 Go runtime 代码的时候,看到有些函数是需要使用 systemstack 进行执行调用的,例如 runtime 中的监控逻辑 sysmon

systemstack(func() {
  newm(sysmon, nil, -1)
})
1
2
3

Go 中关于 systemstack 的注释也非常简洁明了:

// systemstack runs fn on a system stack.
// If systemstack is called from the per-OS-thread (g0) stack, or
// if systemstack is called from the signal handling (gsignal) stack,
// systemstack calls fn directly and returns.
// Otherwise, systemstack is being called from the limited stack
// of an ordinary goroutine. In this case, systemstack switches
// to the per-OS-thread stack, calls fn, and switches back.
// It is common to use a func literal as the argument, in order
// to share inputs and outputs with the code around the call
// to system stack:
//
//	... set up y ...
//	systemstack(func() {
//		x = bigcall(y)
//	})
//	... use x ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

大意是 systemstack 函数在系统栈上执行函数 fn,如果当前为处于 g0 栈或者是信号处理的栈中调用,那么就直接调用函数,并返回。 如果是从普通的 goroutine 栈中调用,则需要切换到 g0 栈执行函数,执行完成之后再切换回去。

我们需要首先了解一下什么是系统栈:

函数的执行或者调用过程中,是需要内存用来存储临时变量、调用参数的,这些数据存储在一个被命名为“栈”的内存区域中,栈的大小在 Linux 中通过如下命令查看:

ulimit -s
1

这个栈的大小一旦在初始化的时候定下来,就不可改变,所以如果你写了一个超长的递归,那么就可能报 Stack Overflow,就是栈空间被用完了,会导致程序直接崩溃。

一般默认为 8MB,这是主线程的大小,程序中创建新线程的大小默认为 2MB(pthread_create (opens new window) 中有相关说明),可以通过 pthread_attr_setstacksize (opens new window) 函数设置。我们知道,Go 面向用户并没有线程的概念,只有 goroutine,由 runtime 负责调度,goroutine 执行同样需要栈内存,但是这个栈并不是使用 goroutine 所在线程的栈,而是自己维护的,初始化大小为 2KB。栈大小会随着需求而扩容。但是如果 goroutine 执行某些特殊的逻辑,是需要切换回系统的栈,这个时候就是使用 systemstack 函数。

上文中所说的 g0 栈,也就是这里的系统栈。

g0 是每个 M 系统线程创建的第一个 goroutine,使用的是系统栈,并不是 runtime 维护的用户栈。g0 的主要职责为 goroutine 管理调度、goroutine 的创建、GC 扫描、栈扩容、defer 函数的初始化等。也就是说每当执行这些操作的时候,runtime 都会切换到 g0 栈上执行。

创建 goroutine 的代码调用 systemstack 切换到 g0:

func newproc(siz int32, fn *funcval) {
	argp := add(unsafe.Pointer(&fn), sys.PtrSize)
	gp := getg()
	pc := getcallerpc()
	systemstack(func() {
		newproc1(fn, argp, siz, gp, pc)
	})
}
1
2
3
4
5
6
7
8

# 为什么有些逻辑要切换到系统栈

举个简单的例子,例如栈扩容,这个操作是在普通的 goroutine 栈空间不足的时候触发的,运行栈扩容的逻辑显然也需要栈空间,这就有点矛盾,因为当前的栈已经快要耗尽了,所以这个时候直接切换到 g0 执行逻辑就好。

其他逻辑例如 cgo 调用,cgo 调用已经脱离了 go runtime 了,栈自然要切换到默认的系统栈,这样才能保证接下来的逻辑被正确的执行。毕竟在 cgo 的代码中,go 的编译器肯定不会插入栈空间检查这类的逻辑,而是完全的 C 代码在执行。

还有 runtime.mheap_.lock 字段的使用必须在 system stack 中使用,否则可能会在栈增长的时候死锁。

runtime/mheap.go (opens new window)

// lock must only be acquired on the system stack, otherwise a g
// could self-deadlock if its stack grows with the lock held.
1
2

上述部分原因一知半解,主要都是通过 runtime 的注释和官方文档推断出来的,可能有误或者含糊不清,待深入研究。

GC 的时候一些操作也会切换到系统栈,例如 STW,STW 会抢占所有的 goroutine,由于当前执行 GC 的 goroutine 在系统栈,不可被抢占,所以当前 GC 会正常的执行进行接下来的逻辑。

如果切换到系统栈,则当前的执行是不可以被抢占的,GC 也不会扫描系统栈,因为切换到了系统栈,所以这个时候所在线程的用户栈 goroutine 肯定也不是在运行的状态。

# systemstack 源码

我们以 go 1.14.4 版本的代码为例,平台为常用的 AMD64。

TEXT runtime·systemstack(SB), NOSPLIT, $0-8
	// 将参数函数 fn 放入 DI 寄存器
	MOVQ	fn+0(FP), DI	// DI = fn
	// 从 TLS 线程本地存储中拿到协程 g 的信息
	get_tls(CX)
	MOVQ	g(CX), AX	// AX = g
	// g 结构体中的 m 字段放入 BX 寄存器
	MOVQ	g_m(AX), BX	// BX = m

	// 判断当前是否是信号栈,如果是的话无需切换,直接跳转到 noswitch 处
	CMPQ	AX, m_gsignal(BX)
	JEQ	noswitch

	// 判断当前是否是 g0 栈,如果是也无需切换
	MOVQ	m_g0(BX), DX	// DX = g0
	CMPQ	AX, DX
	JEQ	noswitch

	CMPQ	AX, m_curg(BX)
	JNE	bad

	// switch stacks
	// save our state in g->sched. Pretend to
	// be systemstack_switch if the G stack is scanned.
	MOVQ	$runtime·systemstack_switch(SB), SI
	// 栈切换,先保存 SI、SP、AX、BP 寄存器到结构体 g.sched 中的对应字段
	// 其中 g.sched.pc 会被保存为 runtime.systemstack_switch 函数的地址
	// 正如源码注释,systemstack_switch 是为了伪装,当栈被扫描的时候,可以识别到栈切换的动作
	// systemstack_switch 函数在 amd64 下是一个空实现,没有逻辑
	MOVQ	SI, (g_sched+gobuf_pc)(AX)
	MOVQ	SP, (g_sched+gobuf_sp)(AX)
	MOVQ	AX, (g_sched+gobuf_g)(AX)
	MOVQ	BP, (g_sched+gobuf_bp)(AX)

	// switch to g0
	// 将 TSL 中的 g 换成 g0,
	MOVQ	DX, g(CX)
	MOVQ	(g_sched+gobuf_sp)(DX), BX
	// make it look like mstart called systemstack on g0, to stop traceback
	// 这里主要是伪装是 mstart 函数被 systemstack 调用,目的是为了 traceback 的时候不会追踪到这里的调用
	// 看来 traceback 检测到 mstart 函数就会停止继续追踪
	// runtime 有两个相关的函数:topofstack、setsSP
	// 下面就是将 runtime·mstart 函数地址放到 g0 栈顶的位置(当前栈顶-8位置)
	SUBQ	$8, BX
	MOVQ	$runtime·mstart(SB), DX
	MOVQ	DX, 0(BX)
	MOVQ	BX, SP

	// call target function
	// DI 寄存器中存储的是并不直接是函数 fn 的地址
	// DI 中存储的是存放函数 fn 地址的地址
	// 所以要想拿到函数地址,需要使用语法 0(DI),取 DI 地址位置的值(函数地址,8个字节)
	// eg: fn_address = 0x40; DI = 0x10; 0x10 内存地址后八个字节内容为 0x40,拿到 0x40 语法为 0(DI)
	// Go 编译器会将函数地址统一存放到一段内存区域内:
	// addr
	// 0x10 +------------------------+
	//      |    fn_address=0x40     |
	// 0x18 +------------------------+
	//      |    fn_b_address=0x90   |
	// ...  +------------------------+
	//      |    ....                |	
	//      +------------------------+
	MOVQ	DI, DX
	MOVQ	0(DI), DI
	CALL	DI

	// switch back to g
	// 这里就没什么好说的了,切换普通 g
	// 恢复 SP 栈寄存器
	get_tls(CX)
	MOVQ	g(CX), AX
	MOVQ	g_m(AX), BX
	MOVQ	m_curg(BX), AX
	MOVQ	AX, g(CX)
	MOVQ	(g_sched+gobuf_sp)(AX), SP
	MOVQ	$0, (g_sched+gobuf_sp)(AX)
	RET

noswitch:
	// already on m stack; tail call the function
	// Using a tail call here cleans up tracebacks since we won't stop
	// at an intermediate systemstack.
	MOVQ	DI, DX
	MOVQ	0(DI), DI
	JMP	DI

bad:
	// Bad: g is not gsignal, not g0, not curg. What is it?
	MOVQ	$runtime·badsystemstack(SB), AX
	CALL	AX
	INT	$3
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

# 栈的扩容

上面说到,普通 g 的栈大小是 Go runtime 管理维护的,且是可变的,但是也是有上限,目前是 1G。

关于 Go 栈管理的发展还有一段历史,从最开始的分段栈再到现在的拷贝栈。

关于这部分主要有两个函数:morestacknewstack,主要就是申请新的栈内存,把旧的挪过去,释放旧的,还有一些细节操作例如调整栈内的指针(新栈的地址变了)等等。

# 参考资料