Go Extension:内存管理の栈内存管理

Go Extension:内存管理の栈内存管理

​ 程序在运行期间可以主动从堆区申请内存空间,这些内存是由内存分配器分配,然后由垃圾回收器回收。每个goroutine都需要自己的栈空间,用来存放函数/局部变量等信息,栈区的内存管理是由编译器完成的。

​ 内存分配器和垃圾收集器主要是围绕堆内存的申请和释放过程,这篇文章会介绍下go栈内存的实现原理,这样go的内存管理就完满了。

1.设计原理

​ 栈区一般存放着函数的入参和局部变量,这些参数的特点就是和函数的生命周期同步,在程序中不会长时间的存在,栈区的内存管理是由编译器自动完成的。

​ 栈区的操作一般使用到两个以上的寄存器,栈寄存器主要是跟踪函数的调用栈。在Go中,主要涉及BPSP两个栈寄存器,分别存储栈的基址指针和栈顶指针。BPSP之间的内存就是当前函数的调用栈。

stack-registers

​ 栈区的内存是从高地址向低地址扩展的,所以申请和释放栈内存时只需要修改SP寄存器。

2.栈分配方式

2.1 固定大小的栈

​ 令每个goroutine的栈都拥有相同的、固定大小的栈。

📄缺点:对所有goroutine一概而论,可能会无法满足某些goroutine的需求,或者造成浪费栈内存;

📄优点:实现比较简单;

2.2 创建时指定

​ 在go中,栈内存是由运行时分配管理的,但是像java/c++,在创建线程时,开发者是可以指定栈大小的。

📄缺点:开发者需要预估分配的栈内存大小,但有些复杂场景,预估可能会不准确;

📄优点:实现比较简单;

2.3 分段栈

​ 初始分配的栈内存空间比较小,如果不够了,那么再继续增加,栈内存用完了,那么就释放。

​ 所有的goroutine在初始化的时候会调用staclalloc()函数分配固定大小的内存空间,在V1.2中为8KB。

  • 通过stackalloc()申请的内存大小为固定的8KB或者满足其他的条件,运行时会在全局的栈缓存链表中找到空闲的内存块并作为新goroutine的栈空间返回;

  • 其他情况,栈内存空间会从堆上申请一块合适的内存。

    goroutine调用的函数层级或者局部遍历需要的越来越多的时候,运行时会通过调用morestacknewstack()创建一个新的栈空间,这些栈空间虽然不连续,但是当前goroutine的多个栈空间会以链表的形式串联起来,运行时会通过指针找到连续的栈片段。

​ 如果栈空间不需要了,那么运行时会调用lessstack()oldstack释放不再使用的内存空间。

📄缺点:

  • 栈的分配和释放会造成热分裂(hot split)问题:如果当前栈几乎满了,那么不管下一个调用的函数是谁,都会触发栈扩容,函数返回之后栈又会缩容。如果在循环中调用某函数,那么栈的反复分配和释放会造成巨大的额外开销。

📄优点:初始化成本小,可以动态扩展

2.4 连续栈

golang V1.3版本之前使用的都是分段栈方式,到了V1.3开始使用连续栈。

Goroutine的栈内存空间和栈结构主要发生过这样一些变化:

  • V1.0~V1.1:最小栈内存空间为4KB
  • V1.2:将最小栈内存提升到了8KB,主要是为了减轻分段栈的栈分裂问题对程序造成的性能影响。
  • V1.3:使用连续栈替换了之前的分段栈

img

​ 连续栈的原理:每当程序的栈空间不足时,初始化一片更大的栈空间并将原来栈中的东西迁移到新的栈中,这样一来,新的局部变量或者函数调用就有了充足的内存空间。

​ 栈空间不足导致的扩容过程有以下几个步骤:

  • 在内存空间中分配更大的栈内存空间
  • 将旧栈中的所有内容复制到新的栈中
  • 将指向旧栈对应变量的指针重新指向新栈;
  • 销毁并回收旧栈的内存空间。

​ 不难看出,最重要的就是第三步调整指针。经过逃逸分析的go程序遵循一个不变性:指向栈对象的指针不能存在于堆中,所以指向栈中变量的指针必须要在栈上,所以这一步可以保证指向栈的指针的正确性。

📄缺点:

  • 可能会带来更多的虚拟内存碎片,如果需要分配一块连续的大内存空间那么会有点困难;

📄优点:动态扩展,初始化成本小,并且解决了V1.3版本之前出现的热分裂问题。

2.栈操作

Go语言中的栈可以由runtime.stack结构体表示,该结构体中只包含两个字段。

type stack struct {
	lo uintptr	//栈的顶部
	hi uintptr	//栈的底部
}

2.1 栈扩容

触发时机:golang在进行目标代码生成的时候会根据函数栈帧大小插入相应的指令,检查当前 goroutine 的栈空间是否足够,如果不够用了,那么会调用morestack函数进行扩容。

分段栈调用morestack时,函数会为goroutine分配新的内存栈,然后把栈信息写入新栈栈底的struct中,并且包括了老栈的地址,这样将两个栈联系在一起,goroutine会从导致栈空间用光的函数开始执行。

但是分段栈的方式,使得新旧栈的空间是不连续的;

而且新栈不会一直使用,新栈的底部插入了函数lessstack,当执行完了导致老栈用光的函数后,会回调lesstack函数,该函数的作用就是根据新栈底部的struct找到老栈的地址,然后使goroutine返回老栈,并释放新栈,然后继续执行goroutine

关键步骤:

​ 不难发现,栈扩容的关键在于栈拷贝,这里概括下copystack的过程:

  • 首先计算老栈和新栈的地址差,作为调整栈基本信息的指针使用:oldsize := gp.stack.hi - gp.stack.lo
  • 申请新的栈空间:new := stackalloc(uint32(newsize))
  • 将老栈的信息全部拷贝到新栈上:memmove
  • 调整新栈上的指针:gentraceback
  • 释放旧栈:stackfree(old)

源码:

func newstack(ctxt unsafe.Pointer) {
    thisg := getg()
    ......
    gp := thisg.m.curg
    .....
    oldsize := gp.stack.hi - gp.stack.lo
    //新栈大小为原来的2倍
    newsize := oldsize * 2
    //var maxstacksize uintptr = 1 << 20 最大的栈大小1G
    if newsize > maxstacksize {
        print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")
        throw("stack overflow")
    }

    // 修改协程状态为栈拷贝
    casgstatus(gp, _Grunning, _Gcopystack)

    // 执行栈拷贝
    copystack(gp, newsize, true)
    if stackDebug >= 1 {
        print("stack grow done\n")
    }
    //将栈拷贝状态改回来
    casgstatus(gp, _Gcopystack, _Grunning)
    //发起调度,继续执行
    gogo(&gp.sched)
}

src/runtime/stack.go#825:栈拷贝copystack

func copystack(gp *g, newsize uintptr, sync bool) {
    ....
    //计算栈已经使用的大小
    old := gp.stack
    if old.lo == 0 {
        throw("nil stackbase")
    }
    used := old.hi - gp.sched.sp

    // 分配新的栈
    new := stackalloc(uint32(newsize))
    if stackPoisonCopy != 0 {
        fillstack(new, 0xfd)
    }
    ....
    var adjinfo adjustinfo
    adjinfo.old = old
    adjinfo.delta = new.hi - old.hi
    ncopy := used
    if sync {
        adjustsudogs(gp, &adjinfo)
    } else {
        adjinfo.sghi = findsghi(gp, old)
        ncopy -= syncadjustsudogs(gp, used, &adjinfo)
    }

    //现在老栈的内容拷贝到新栈上
    memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)
    //调整ctxt、defers、panics、sghi指针的位置
    adjustctxt(gp, &adjinfo)
    adjustdefers(gp, &adjinfo)
    adjustpanics(gp, &adjinfo)
    if adjinfo.sghi != 0 {
        adjinfo.sghi += adjinfo.delta
    }

    // Swap out old stack for new one
    gp.stack = new
    gp.stackguard0 = new.lo + _StackGuard // NOTE: might clobber a preempt request
    gp.sched.sp = new.hi - used
    gp.stktopsp += adjinfo.delta

    // 调整新栈上的指针
    gentraceback(^uintptr(0), ^uintptr(0), 0, gp, 0, nil, 0x7fffffff, adjustframe, noescape(unsafe.Pointer(&adjinfo)), 0)

    // 释放老栈
    if stackPoisonCopy != 0 {
        fillstack(old, 0xfc)
    }
    stackfree(old)
}

补充:

  • 新栈的大小是老栈的2倍,这有个问题就是可能会造成空间浪费,但实际上带来的性能还是客观的。
  • 栈上也是会存储变量的,如果存在某些指针指向了栈上的变量,那么在进行栈扩容的时候(旧栈copy到新栈,栈拷贝),这些变量的地址也随之变化,为了让这些指向栈上变量的指针保持有效,我们需要在栈拷贝的时候,修改这些指针指向的位置。
  • 但是golang运行时代码有一部分是C语言写的,所以没法获取到这些指针的位置,后来golang把很多运行时代码都golang化,如果代码还是C语言写的,那么只能继续使用分段栈了。

2.2 栈缩容

**触发时机:**栈的缩容是发生在垃圾回收,主动触发,如果当前goroutine在垃圾回收的时候被runtime.scanstack函数标记成为了需要收缩的栈,那么就会调用shrinkstack函数。shrinkstack函数执行缩容的主要判断逻辑是如果当前使用的栈空间小于可用栈空间的 1 / 4 1/4 1/4,那么执行缩容。

关键步骤:

  • 获取缩容前的栈大小:oldsize := gp.stack.hi - gp.stack.lo
  • 缩容后的栈大小为原来的一半:newsize := oldsize / 2
  • 判断新栈大小是否小于2KB:if newsize < _FixedStack
  • 获取当前可用栈大小:avail := gp.stack.hi - gp.stack.lo
  • 判断新栈大小是否小于可用栈的四分之一:used >= avail/4
  • 栈拷贝:copystack(gp, newsize)

关键源码:

// Maybe shrink the stack being used by gp.
// Called at garbage collection time.
// gp must be stopped, but the world need not be.
func shrinkstack(gp *g) {
    gstatus := readgstatus(gp)
    if gp.stack.lo == 0 {
        throw("missing stack in shrinkstack")
    }
    if gstatus&_Gscan == 0 {
        throw("bad status in shrinkstack")
    }

    if debug.gcshrinkstackoff > 0 {
        return
    }
    f := findfunc(gp.startpc)
    if f.valid() && f.funcID == funcID_gcBgMarkWorker {
        // We're not allowed to shrink the gcBgMarkWorker
        // stack (see gcBgMarkWorker for explanation).
        return
    }

    oldsize := gp.stack.hi - gp.stack.lo
    //缩小为原来的1/2
    newsize := oldsize / 2 
    //如果新栈大小小于程序最低的限制_FixedStack(2KB),那么缩容的过程就会停止。
    if newsize < _FixedStack {
        return
    }
    // Compute how much of the stack is currently in use and only
    // shrink the stack if gp is using less than a quarter of its
    // current stack. The currently used stack includes everything
    // down to the SP plus the stack guard space that ensures
    // there's room for nosplit functions.
    avail := gp.stack.hi - gp.stack.lo
    // 如果当前使用的栈空间已经达到可用栈空间的1/4,则不进行缩容
    if used := gp.stack.hi - gp.sched.sp + _StackLimit; used >= avail/4 {
        return
    }

    // We can't copy the stack if we're in a syscall.
    // The syscall might have pointers into the stack.
    if gp.syscallsp != 0 {
        return
    }
    if sys.GoosWindows != 0 && gp.m != nil && gp.m.libcallsp != 0 {
        return
    }

    if stackDebug > 0 {
        print("shrinking stack ", oldsize, "->", newsize, "\n")
    }
	//当栈内存使用不足1/4的时候,实际上是通过调用栈拷贝函数开辟新的空间,和栈扩容的时候调用的是同一个函数
    copystack(gp, newsize, false)
}

3.小结

​ 之前学习其他语言的时候比如C和Java,都有比较明确的堆和栈的概念。就好比Java,我们知道Java虚拟机在执行Java程序的过程中会把所管理的内存划分为不同的数据区域,每个区域都有自己的规则,如Java堆存放对象实例。

​ 但是在Go中,我们似乎不太明确一个变量分配在heap还是stack上,这里在写这篇笔记的时候翻看了下GoFAQ,有这样一段解释:

How do I know whether a variable is allocated on the heap or the stack?

From a correctness standpoint, you don’t need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.

The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function’s stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack.

In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack.

把加粗的地方翻一下:

1.如果可能,编译器会为该函数的堆栈帧分配本地变量;

2.如果函数返回值后,编译器没办法证明变量未被引用,那么就要在堆上分配变量,避免悬挂指针的情况;

3.如果局部变量比较大,那么分配到堆上比较有意义;

​ 实际上,变量最终分配在哪儿,还是由go编译器决定的。为了保证内存的绝对安全,编译器可能会把一些变量错误的分配到堆上,但是最终也会被垃圾收集器回收。

Link

分配到堆上比较有意义;

​ 实际上,变量最终分配在哪儿,还是由go编译器决定的。为了保证内存的绝对安全,编译器可能会把一些变量错误的分配到堆上,但是最终也会被垃圾收集器回收。

Link

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值