一文详解Golang内存管理之栈空间管理
作者:IguoChan
0. 简介
前面我们分别介绍了堆空间管理的内存分配器和垃圾收集,这里我们简单介绍一下Go中栈空间的管理。
1. 系统栈和Go栈
1.1 系统线程栈
如果我们在Linux中执行 pthread_create
系统调用,进程会启动一个新的线程,这个栈大小一般为系统的默认栈大小,比如在以下系统中,栈大小是8192KB
,也就是8M大小。
$ ulimit -a core file size (blocks, -c) 0 data seg size (kbytes, -d) unlimited scheduling priority (-e) 0 file size (blocks, -f) unlimited pending signals (-i) 128528 max locked memory (kbytes, -l) 64 max memory size (kbytes, -m) unlimited open files (-n) 4194304 pipe size (512 bytes, -p) 8 POSIX message queues (bytes, -q) 819200 real-time priority (-r) 0 stack size (kbytes, -s) 8192 cpu time (seconds, -t) unlimited max user processes (-u) 515129 virtual memory (kbytes, -v) unlimited file locks (-x) unlimited
对于栈上的内存,程序员无法直接操作,由系统统一管理,一般的函数参数、局部变量(C语言)会存储在栈上。
1.2 Go栈
Go语言在用户空间实现了一套runtime
的管理系统,其中就包括了对内存的管理,Go的内存也区分堆和栈,但是需要注意的是,Go栈内存其实是从系统堆中分配的内存,因为同样运行在用户态,Go的运行时也没有权限去直接操纵系统栈。
Go语言使用用户态协程goroutine作为执行的上下文,其使用的默认栈大小比线程栈高的多,其栈空间和栈结构也在早期几个版本中发生过一些变化:
- v1.0 ~ v1.1 — 最小栈内存空间为 4KB;
- v1.2 — 将最小栈内存提升到了 8KB;
- v1.3 — 使用连续栈替换之前版本的分段栈;
- v1.4 — 将最小栈内存降低到了 2KB;
2. 栈操作
在前面的《Golang调度器》系列我们也讲过,Go语言中的执行栈由runtime.stack
,该结构体中只包含两段字段,分别表示栈的顶部和底部,每个栈结构体都在[lo, hi)
的范围内:
type stack struct { lo uintptr hi uintptr }
栈的结构虽然非常简单,但是想要理解 Goroutine 栈的实现原理,还是需要我们从编译期间和运行时两个阶段入手:
- 编译器会在编译阶段会通过
cmd/internal/obj/x86.stacksplit
在调用函数前插入runtime.morestack
或者runtime.morestack_noctxt
函数; - 运行时创建新的 Goroutine 时会在
runtime.malg
中调用runtime.stackalloc
申请新的栈内存,并在编译器插入的runtime.morestack
中检查栈空间是否充足;
当然,可以在函数头加上//go:nosplit
跳过栈溢出检查。
2.1 栈初始化
栈空间运行时中包含两个重要的全局变量,分别是stackpool
和stackLarge
,这两个变量分别表示全局的栈缓存和大栈缓存,前者可以分配小于 32KB 的内存,后者用来分配大于 32KB 的栈空间:
var stackpool [_NumStackOrders]struct { item stackpoolItem _ [cpu.CacheLinePadSize - unsafe.Sizeof(stackpoolItem{})%cpu.CacheLinePadSize]byte } //go:notinheap type stackpoolItem struct { mu mutex span mSpanList } // Global pool of large stack spans. var stackLarge struct { lock mutex free [heapAddrBits - pageShift]mSpanList // free lists by log_2(s.npages) }
其初始化函数如下,从下也可以看出,Go栈的内存都是分配在堆上的:
func stackinit() { if _StackCacheSize&_PageMask != 0 { throw("cache size must be a multiple of page size") } for i := range stackpool { stackpool[i].item.span.init() lockInit(&stackpool[i].item.mu, lockRankStackpool) } for i := range stackLarge.free { stackLarge.free[i].init() lockInit(&stackLarge.lock, lockRankStackLarge) } }
2.2 栈分配
我们在这里会按照栈的大小分两部分介绍运行时对栈空间的分配。在 Linux 上,_FixedStack = 2048
、_NumStackOrders = 4
、_StackCacheSize = 32768
,也就是如果申请的栈空间小于 32KB,我们会在全局栈缓存池或者线程的栈缓存中初始化内存:
//go:systemstack func stackalloc(n uint32) stack { ... thisg := getg() ... var v unsafe.Pointer if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize { order := uint8(0) n2 := n for n2 > _FixedStack { order++ n2 >>= 1 } var x gclinkptr if stackNoCache != 0 || thisg.m.p == 0 || thisg.m.preemptoff != "" { // thisg.m.p == 0 can happen in the guts of exitsyscall // or procresize. Just get a stack from the global pool. // Also don't touch stackcache during gc // as it's flushed concurrently. lock(&stackpool[order].item.mu) x = stackpoolalloc(order) // 全局栈缓存池 unlock(&stackpool[order].item.mu) } else { c := thisg.m.p.ptr().mcache // 线程缓存的栈缓存中 x = c.stackcache[order].list if x.ptr() == nil { // 不够就调用stackcacherefill从堆上获取 stackcacherefill(c, order) x = c.stackcache[order].list } c.stackcache[order].list = x.ptr().next c.stackcache[order].size -= uintptr(n) } v = unsafe.Pointer(x) } else { ... } ... return stack{uintptr(v), uintptr(v) + uintptr(n)} }
如果申请的内存空间过大,运行时会查看runtime.stackLarge
中是否有剩余的空间,如果不存在剩余空间,它也会从堆上申请新的内存:
//go:systemstack func stackalloc(n uint32) stack { ... thisg := getg() ... var v unsafe.Pointer if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize { ... } else { var s *mspan npage := uintptr(n) >> _PageShift log2npage := stacklog2(npage) // Try to get a stack from the large stack cache. lock(&stackLarge.lock) if !stackLarge.free[log2npage].isEmpty() { // 从stackLarge拿 s = stackLarge.free[log2npage].first stackLarge.free[log2npage].remove(s) } unlock(&stackLarge.lock) lockWithRankMayAcquire(&mheap_.lock, lockRankMheap) if s == nil { // 从堆拿 // Allocate a new stack from the heap. s = mheap_.allocManual(npage, spanAllocStack) if s == nil { throw("out of memory") } osStackAlloc(s) s.elemsize = uintptr(n) } v = unsafe.Pointer(s.base()) } ... return stack{uintptr(v), uintptr(v) + uintptr(n)} }
2.3 栈扩容
在之前我们就提过,编译器会在cmd/internal/obj/x86.stacksplit
中为函数调用插入runtime.morestack
运行时检查,它会在几乎所有的函数调用之前检查当前 Goroutine 的栈内存是否充足,如果当前栈需要扩容,我们会保存一些栈的相关信息并调用runtime.newstack
创建新的栈。
在此期间可能触发抢占。
接下来就是分配新的栈内存和栈拷贝,这里就不详细描述了。
2.4 栈缩容
runtime.shrinkstack
栈缩容时调用的函数,该函数的实现原理非常简单,其中大部分都是检查是否满足缩容前置条件的代码,核心逻辑只有以下这几行:
func shrinkstack(gp *g) { ... oldsize := gp.stack.hi - gp.stack.lo newsize := oldsize / 2 if newsize < _FixedStack { return } avail := gp.stack.hi - gp.stack.lo if used := gp.stack.hi - gp.sched.sp + _StackLimit; used >= avail/4 { return } copystack(gp, newsize) }
如果要触发栈的缩容,新栈的大小会是原始栈的一半,不过如果新栈的大小低于程序的最低限制2KB
,那么缩容的过程就会停止。
运行时只会在栈内存使用不足1/4
时进行缩容,缩容也会调用扩容时使用的runtime.copystack
开辟新的栈空间。
以上就是一文详解Golang内存管理之栈空间管理的详细内容,更多关于Golang栈空间管理的资料请关注脚本之家其它相关文章!