Golang内存管理—栈空间管理
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
开辟新的栈空间。
3. 参考文献
转载自:https://juejin.cn/post/7250278945412104248