之前在尝试阅读 Go runtime 代码的时候,看到有些函数是需要使用 systemstack
进行执行调用的,例如 runtime 中的监控逻辑 sysmon
:
systemstack(func() {
newm(sysmon, nil, -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 ...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
大意是 systemstack
函数在系统栈上执行函数 fn,如果当前为处于 g0 栈或者是信号处理的栈中调用,那么就直接调用函数,并返回。
如果是从普通的 goroutine 栈中调用,则需要切换到 g0 栈执行函数,执行完成之后再切换回去。
我们需要首先了解一下什么是系统栈:
函数的执行或者调用过程中,是需要内存用来存储临时变量、调用参数的,这些数据存储在一个被命名为“栈”的内存区域中,栈的大小在 Linux 中通过如下命令查看:
ulimit -s
这个栈的大小一旦在初始化的时候定下来,就不可改变,所以如果你写了一个超长的递归,那么就可能报 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)
})
}
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.
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
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 栈管理的发展还有一段历史,从最开始的分段栈再到现在的拷贝栈。
关于这部分主要有两个函数:morestack
和 newstack
,主要就是申请新的栈内存,把旧的挪过去,释放旧的,还有一些细节操作例如调整栈内的指针(新栈的地址变了)等等。
# 参考资料
- https://github.com/golang/go/blob/master/src/runtime/HACKING.md (opens new window)
- https://medium.com/a-journey-with-go/go-g0-special-goroutine-8c778c6704d8 (opens new window)
- https://groups.google.com/g/golang-nuts/c/JCKWH8fap9o (opens new window)
- https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-garbage-collector/ (opens new window)
- https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-stack-management/ (opens new window)
- https://zboya.github.io/post/go_scheduler/ (opens new window)
- https://github.com/cch123/asmshare (opens new window)