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

相关推荐
无限进步_10 分钟前
C++从入门到类和对象完全指南
开发语言·c++·windows·git·后端·github·visual studio
lalala_lulu13 分钟前
Lambda表达式是什么
开发语言·python
她说..13 分钟前
Java AOP完全指南:从原理到实战(全套知识点+场景总结)
java·开发语言·spring·java-ee·springboot
Sammyyyyy13 分钟前
Rust性能调优:从劝退到真香
开发语言·后端·rust·servbay
Zfox_18 分钟前
【Go】异常处理、泛型和文件操作
开发语言·后端·golang
浪客川25 分钟前
高效日志分离器:一键筛选关键信息
开发语言·windows·c#
星竹晨L28 分钟前
C++红黑树:理论与实践相结合的平衡艺术
开发语言·数据结构·c++
itwangyang52029 分钟前
在 GitHub 上生成和配置个人访问令牌(PAT),并将其用于 R 环境中的凭证管理和包安装。
开发语言·r语言·github