大家好,我是飞哥!
大家在学校里的计算机课程上都学过进程和线程。但是现在工作中又绝大部分用的是协程了。不少同学就开始对协程犯迷糊,只知道它轻量,但不知道它和之前所理解的进程、线程有什么区别。
今天我从内存的角度来和大家聊聊,看看 Golang 线程协程使用栈内存的方式有什么特别的地方。说明:本文提到的 golang 源码使用的是 1.18.6 版本
一、进程栈 && glibc 线程栈
为了让大家能融会贯通,在开始讨论协程栈之前我们先来回顾下一下进程栈与线程栈。
在 Linux 内核,进程是用一个 task_struct 来表示的,其所有内存相关的数据结构都在其 mm_struct 中表示。在 mm_struct 中用一棵红黑树表示进程当前分配的地址空间,每一个红黑树节点都表示地址空间中已经申请的一段范围。
进程启动调用 exec 加载可执行文件过程的时候,会给进程栈申请一个 4 KB 的初始内存。之后当栈中的存储超过 4KB 的时候会自动进行扩大。不过大小要受到限制,其大小限制可以通过 ulimit -s来查看和设置
整体上,一个进程的栈区的实现示意图如下所示。

我们假设现在进程通过调用 glibc 的 pthread_create 又创建了一个新的线程出来。
每个线程都需要有独立的栈,来保证并发调度时不冲突。然而进程地址空间的默认栈由于多个 task_struct 所共享的,所以线程必须通过 mmap 来独立管理自己的栈。
Linux 下 glibc 中的线程库其实是 nptl 线程。它包含了两部分的资源。第一部分是在用户态管理了用户态的线程对象 struct pthread,以及独立的线程栈。第二部分就是内核中的 task_struct,地址空间等内核对象。进程栈和线程栈的关系如下图所示。

这里要注意的是,glibc 中的 struct pthread 是用户态的变量。而 task_struct 和 mm_struct 都是内核态的变量。这个逻辑关系要搞清楚。
二、Golang 的线程栈与协程栈
从上一小节可以看到,线程没有办法使用操作系统默认给进程分配的栈内存。Linux 中 glibc 库的做法是自己申请内存来当线程栈用。
Golang 中既有线程的概念,也有协程的概念,每一个线程上还会运行多个协程。无论是线程栈,还是协程栈,都得自己来手工申请和管理。我们来看看 golang 是如何维护线程栈和协程栈的。
2.1 线程栈的分配
Golang 中的线程和 glibc 中的线程库类似,也是先自己申请内存,然后再通过 clone 系统调用通知操作系统来创作线程的。 我们来浏览下 Golang 的线程创建函数 newm。
go
// file:runtime/proc.go
// Create a new m. It will start off with a call to fn, or else the scheduler.
func newm(fn func(), _p_ *p, id int64) {
// 申请线程对象以及默认的 g0
mp := allocm(_p_, fn, id)
...
// 调用 clone 系统调用真正创建线程
newm1(mp)
}
在 allocm 中申请一个表示线程的结构体的对象,然后再为其默认创建一个 g0。每个线程都有 g0 这么一个特殊的协程,用途包括用来创建其它的协程等等。这个特殊的协程 g0 是随着线程 m 对象一起被创建出来的。
go
//file:runtime/proc.go
func allocm(_p_ *p, fn func(), id int64) *m {
...
// 申请表示线程的 m 对象
mp := new(m)
mp.mstartfn = fn
// 创建协程 g0
if iscgo || mStackIsSystemAllocated() {
mp.g0 = malg(-1)
} else {
mp.g0 = malg(8192 * sys.StackGuardMultiplier)
}
mp.g0.m = mp
return mp
}
其中 malg 函数的功能是创建一个协程 g,所接收的参数是协程栈的大小。可以看到给 g0 申请的栈还是挺大的,默认给了 8 KB(普通协程栈默认只有 2KB)。
go
// file:runtime/proc.go
func malg(stacksize int32) *g {
// 申请表示协程的 g 对象
newg := new(g)
if stacksize >= 0 {
...
// 切换到 G0 为 newg 初始化栈内存
systemstack(func() {
newg.stack = stackalloc(uint32(stacksize))
})
// 设置 stackguard0 ,用来判断是否要进行栈扩容
newg.stackguard0 = newg.stack.lo + _StackGuard
newg.stackguard1 = ^uintptr(0)
}
return newg
}
在上面的 malg 源码中,通过 new 申请了协程的 g 对象,通过 stackalloc 为其申请了内存,还为它设置了 guard(将来判断是否需要扩容栈时用到)。
上面的这些操作都是 Golang 在用户态所执行的。还没有涉及到操作系统中线程的创建。真正操作系统中的线程是需要通过调用 CLONE 系统调用来完成的,而且需要指定线程所使用的栈。
Golang 在 Linux 平台上的做法是直接将 g0 这个特殊的协程的栈当成线程栈给 CLONE 传递了进去。执行的地方是 newm1 => newosproc。
go
//file:runtime/os_linux.go
const (
cloneFlags = _CLONE_VM | /* share memory */
_CLONE_FS | /* share cwd, etc */
_CLONE_FILES | /* share fd table */
_CLONE_SIGHAND | /* share sig handler table */
_CLONE_SYSVSEM | /* share SysV semaphore undo lists (see issue #20763) */
_CLONE_THREAD /* revisit - okay for now */
)
func newosproc(mp *m) {
// 把自己的 g0 的栈拿出来用
stk := unsafe.Pointer(mp.g0.stack.hi)
...
// stk 是当前线程的 g0 协程的栈
ret := clone(cloneFlags, stk, ..., unsafe.Pointer(abi.FuncPCABI0(mstart)))
}
在 clone 系统调用中,操作系统真正创建线程,栈是由 Golang 指定的 g0 协程的栈,入口函数是汇编实现的 mstart 函数。
2.2 协程栈的分配
其实理解了线程栈的创建过程,协程栈的创建就非常容易理解了。我们就以默认的主协程为例,看看它的栈是如何被分配的。
主协程是在 Golang 的汇编入口中创建的,
go
// file:runtime/asm_amd64.s
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
...
// 调用 runtime·newproc 创建一个协程
CALL runtime·newproc(SB)
...
主协程的创建仍然是由 malg 来创建的,它是通过 runtime·newproc => runtime·newproc1 依次被调用到的。这里要注意的是,给普通协程指定的栈的默认大小是 _StackMin,只有 2KB。
go
// file:runtime/proc.go
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
...
//从缓存中获取或者创建 G 对象
newg := gfget(_p_)
if newg == nil {
newg = malg(_StackMin)
...
}
...
//协程入口函数
newg.startpc = fn.fn
...
return newg
}
三、golang 协程栈的扩张
上一小节我们看到,m 中的 g0 协程默认给了 8 KB 的内存,其它普通协程默认给的是 2 KB。在函数调用的过程中,对栈内存的需求会通过函数调用深度,以及局部变量的定义而逐步增加。无论是 8 KB 还是 2 KB,在运行的过程中都可能会存在不够用的情况。
那如果默认的栈内存用光了咋办,肯定不能让程序直接崩溃了之。Golang 的做法是动态地进行判断,当发现内存不够用的时候,再申请一个比原来的栈大两倍的空间。把原来栈中的内容都拷贝到新栈上,同时释放旧栈消耗的内存空间。
3.1 判断是否需要扩张
前面我们看到 malg 创建协程的时候,设置的很重要的栈边界,stackguard0 和 stackguard1 。这些 guard 变量可以理解为一个哨兵,通过和它的地址进行比较来判断是否需要扩展栈。
一个协程创建完后,它的栈内存相关的成员如下:

其中
-
stackguard0:stack.lo + StackGuard, 用于stack overlow的检测;
-
StackGuard:保护区大小,常量Linux上为 928 字节;
另外在理解 golang 是如何判断出来栈需要扩张之前,需要先弄清除一个寄存器 SP。在 Golang 中,该寄存器永远指向栈顶。要注意的是,栈的增长方向是自顶向下的,所以当栈增长的时候,SP 会变小。
go
SUBQ $24, SP // 增长栈是对 sp 做减法,为函数分配函数栈帧
ADDQ $24, SP // 缩减栈是对 sp 做加法 ,清除函数栈帧
我们用个图片来看一下更直观。

协程在运行的时候,不停地通过调用 SUBQ 来为函数调用分配函数帧,调用 ADDQ 来清楚函数帧。如果函数调用的深度比较深,或者是局部变量占用的内存比较大。那么栈空间可能会不够用。
前面我们在给协程申请内存的时候,设置好了 stackguard0,这算是个警戒水位。只要栈内存在分配的时候判断一下是否超过了 stackguard0(栈增长是从大往下增长的,所以实际判断的是是否小于 stackguard0 了),就可以决定是否需要为当前的协程分配更大的栈空间了。
我们可以写一段简单的代码来验证一下:
go
func main() {
n := 1
_ = func1(n)
}
func func1(n int) int {
_ = make([]byte, 200)
return n
}
在上面这段简单的代码中,在 func 1 的函数调用中申请了 200 字节大小的局部变量。 Golang 会在运行时判断当 func 1 运行的时候是否需要进行栈扩充。我们来观察下其汇编源码。
go
# GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go > main.s
在输出的结果中,找到 func1 对应的汇编代码。前面几句就是注入的栈扩展判断的代码。汇编比较难理解,大家也不用细看。只要了解大概逻辑就行了。
go
"".func1 STEXT size=143 args=0x8 locals=0xd8 funcid=0x0 align=0x0
0x000000000 (main.go:9) TEXT "".func1(SB), ABIInternal, $216-8
0x000000000 (main.go:9) LEAQ -88(SP), R12
0x000500005 (main.go:9) CMPQ R12, 16(R14)
0x000900009 (main.go:9) PCDATA $0, $-2
0x000900009 (main.go:9) JLS 120
......
0x007800120 (main.go:11) NOP
0x007800120 (main.go:9) PCDATA $1, $-1
0x007800120 (main.go:9) PCDATA $0, $-2
0x007800120 (main.go:9) MOVQ AX, 8(SP)
0x007d00125 (main.go:9) NOP
0x008000128 (main.go:9) CALL runtime.morestack_noctxt(SB)
上面的 CMPQ 就是将当前的栈顶寄存器 SP 计算下后和当前协程的 stackguard0 来比较。(R14 指向的是当前的协程 g 的变量地址,16(R14)指的是当前协程的 stackguard0 )。
如果判断需要扩展栈,那么 JLS 会跳转到下面执行,通过 runtime.morestack_noctxt(SB) 来扩充栈内存。
到这里有的同学可能会说了,如果每次函数调用都先判断一下是否需要进行栈扩张,那岂不是 golang 的函数调用效率会比较差。事实上,golang 也会在编译时进行优化,当编译时决定肯定不会溢出的情况,就不会生成这段判断代码了。有兴趣你可以把上面的局部变量的大小改成 10 - 20 之类的小数试试。
3.2 函数栈扩张
当 Golang 程序运行时发现当前协程的栈过小了,就需要调用 runtime.morestack_noctxt(SB) 来扩充栈内存。这是我们上一小节得出的结论。
那么具体栈扩张是如何操作的呢。其实原理很简单,就是再额外申请一块比当前栈空间更大的内存,把原来的数据拷贝过来,旧内存释放掉就完了。我们来看下相关源码。
其中栈扩张的入口 runtime.morestack_noctxt 是汇编源码,最终的栈分配会进入到 go 源码 runtime.newstack 中。
go
//file:runtime/stack.go
func newstack() {
// 新栈空间是旧栈大小的两倍
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
...
// 申请新栈并拷贝旧栈
copystack(gp, newsize)
...
}
上面代码中计算出了新栈的大小,然后调用 copystack 完成新栈申请和初始化。
go
//file:runtime/stack.go
func copystack(gp *g, newsize uintptr) {
// 申请新栈
new := stackalloc(uint32(newsize))
// 拷贝旧栈
memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)
// 使用上新的栈了
gp.stack = new
gp.stackguard0 = new.lo + _StackGuard
// 释放旧栈
stackfree(old)
}
整体栈扩张的核心代码就这么多。其中在 stackalloc 申请栈的源码中,由于 Golang 于考虑了很多内存性能优化,所以会有些小复杂。
函数 stackalloc 申请内存时并不是直接通过 mmap 向操作系统申请的,而是自己会按照所需的内存的大小的不同提前预申请一堆,用的时候直接分配。只有预申请用光的时候通过 mmap 向操作系统发起真正的申请。具体源码我们就不过度展开了。
当然了和栈扩展相对应的还有栈收缩的逻辑。感兴趣的同学可以自行在源码中或者 Google 搜索 shrinkstack。
三、总结
在 Linux 中,进程在创建的时候,启动调用 exec 加载可执行文件过程的时候,操作系统会为其分配一个栈内存供进程运行时使用。
Linux 中其实是没有线程的概念的,我们编程中所用的线程都是在用户态中申请内存,然后调用 clone 系统调用来创建的。在 Glibc 中是这样,在 Golang 中也是如此。Golang 创建线程的时候,会默认创建一个 g0 协程,并申请一段栈内存,传给操作系统。对于非 g0 协程,每个协程都会申请一段栈内存。
Golang 中为了支持高并发,所以默认情况下给栈分配的内存都比较小,普通栈默认只有 2 KB。在运行的过程中,会在编译的源码中插入栈溢出判断。如果超过警戒位,就会申请一块两倍大小的新内存,将旧栈中数据拷贝过来后就使用新栈了。
