一文详解Golang内存管理之栈空间管理

0 简介 前面我们分别介绍了堆空间管理的内存分配器和垃圾收集,这里我们简单介绍一下Go中栈空间的管理。 1 系统栈和Go栈 1 1 系统线程栈 如果我们在L

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 栈初始化

栈空间运行时中包含两个重要的全局变量,分别是stackpoolstackLarge,这两个变量分别表示全局的栈缓存和大栈缓存,前者可以分配小于 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栈空间管理的资料请关注好代码网其它相关文章!