谈谈 Golang 中的线程协程是如何管理栈内存的

大家好,我是飞哥!

大家在学校里的计算机课程上都学过进程和线程。但是现在工作中又绝大部分用的是协程了。不少同学就开始对协程犯迷糊,只知道它轻量,但不知道它和之前所理解的进程、线程有什么区别。

今天我从内存的角度来和大家聊聊,看看 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。在运行的过程中,会在编译的源码中插入栈溢出判断。如果超过警戒位,就会申请一块两倍大小的新内存,将旧栈中数据拷贝过来后就使用新栈了。

相关推荐
苏三说技术1 小时前
Claude Code从失控到起飞,只用了这些技巧
后端
长栎2 小时前
写 for 循环写了十年,你却从没用过迭代器模式最狠的那一面
后端
LiaCode2 小时前
Redis 在生产项目的使用
前端·后端
用户559822481222 小时前
Docker Compose Down 导致容器数据误删——ext4 日志恢复全记录
后端
LiaCode2 小时前
一天学完 redis 的爽翻版核心知识总结
前端·后端
大刚测试开发实战2 小时前
如何内网穿透访问本地私有化部署的TestHub
前端·后端·github
xiaodaoluanzha2 小时前
迄今為止,最簡單的編程語言 Nolang
前端·后端
Csvn2 小时前
Docker 容器管理入门 — 从镜像到容器编排
后端
用户762352425913 小时前
ShardingJDBC
后端
行者全栈架构师3 小时前
IDEA 中 Maven 项目的 15 个红色报错快速解决方法
java·后端