Go的堆栈分配


1. Golang的程序栈

  • 每个goroutine维护着一个栈空间,默认最大为4KB
  • 当goroutine的栈空间不足时,golang会调用runtime.morestack(汇编实现:asm_xxx.s)来进行动态扩容
  • 连续栈:当栈空间不足的时候申请一个2倍于当前大小的新栈,并把所有数据拷贝到新栈, 接下来的所有调用执行都发生在新栈上。
  • 每个function维护着各自的栈帧(stack frame),当function退出时会释放栈帧

1.1. function内部的栈操作

用一段简单的代码来说明Go函数调用及传参时的栈操作:

package main

func g(p int) int {
     return p+1;
}

func main() {
     c := g(4) + 1
     _ = c
}

执行go tool compile -S main.go生成汇编,并截取其中的一部分来说明一下程序调用时的栈操作

"".g t=1 size=17 args=0x10 locals=0x0
    // 初始化函数的栈地址
    // 0-16表示函数初始地址为0,数据大小为16字节(input: 8字节,output: 8字节)
    // SB是函数寄存器
    0x0000 00000 (test_stack.go:3)  TEXT    "".g(SB), $0-16
    // 函数的gc收集提示。提示0和1是用于局部函数调用参数,需要进行回收
    0x0000 00000 (test_stack.go:3)  FUNCDATA    $0, gclocals·aef1f7ba6e2630c93a51843d99f5a28a(SB)
    0x0000 00000 (test_stack.go:3)  FUNCDATA    $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
    // FP(frame point)指向栈底
    // 将FP+8位置的数据(参数p)放入寄存器AX
    0x0000 00000 (test_stack.go:4)  MOVQ    "".p+8(FP), AX
    0x0005 00005 (test_stack.go:4)  MOVQ    (AX), AX
    // 寄存器值自增
    0x0008 00008 (test_stack.go:4)  INCQ    AX
    // 从寄存器中取出值,放入FP+16位置(返回值)
    0x000b 00011 (test_stack.go:4)  MOVQ    AX, "".~r1+16(FP)
    // 返回,返回后程序栈的空间会被回收
    0x0010 00016 (test_stack.go:4)  RET
    0x0000 48 8b 44 24 08 48 8b 00 48 ff c0 48 89 44 24 10  H.D$.H..H..H.D$.
    0x0010 c3                                               .
"".main t=1 size=32 args=0x0 locals=0x10
    0x0000 00000 (test_stack.go:7)  TEXT    "".main(SB), $16-0
    0x0000 00000 (test_stack.go:7)  SUBQ    $16, SP
    0x0004 00004 (test_stack.go:7)  MOVQ    BP, 8(SP)
    0x0009 00009 (test_stack.go:7)  LEAQ    8(SP), BP
    0x000e 00014 (test_stack.go:7)  FUNCDATA    $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
    0x000e 00014 (test_stack.go:7)  FUNCDATA    $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
    // SP(stack point)指向栈顶
    // 把4存入SP的位置
    0x000e 00014 (test_stack.go:8)  MOVQ    $4, "".c(SP)
    // 这里会看到没有第9行`call g()`的调用出现,这是因为go汇编编译器会把一些短函数变成内嵌函数,减少函数调用
    0x0016 00022 (test_stack.go:10) MOVQ    8(SP), BP
    0x001b 00027 (test_stack.go:10) ADDQ    $16, SP
    0x001f 00031 (test_stack.go:10) RET

事实上,即便我定义了指针调用,以上的数据也都是在栈上拷贝的;那么Golang中的数据什么时候会被分配到堆上呢?

2. Golang逃逸分析

  • 在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法,用于分析在程序的哪些地方可以访问到指针。
  • Golang在编译时的逃逸分析可以减少gc的压力,不逃逸的对象分配在栈上,当函数返回时就回收了资源,不需要gc标记清除。
  • 如果你定义的对象的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行,提高效率。

2.1. 举个栗子

还是1.1里的那段程序代码,我们可以执行go build -gcflags '-m -l' test_stack.go来进行逃逸分析,输出结果如下

# command-line-arguments
./test_stack.go:3: g p does not escape
./test_stack.go:9: main &c does not escape

可以看到,对象c是没有逃逸的,还是分配在栈上。

即便在一开始定义的时候直接把c定义为指针:

package main

func g(p *int) int {
    return *p + 1
}

func main() {
    c := new(int)
    (*c) = 4
    _ = g(c)
}

逃逸分析的结果仍然不会改变

# command-line-arguments
./test_stack.go:3: g p does not escape
./test_stack.go:8: main new(int) does not escape

那么,什么时候指针对象才会逃逸呢?

2.2. 按值传递和按址传递

  • 按值传递
package main

func g(p int) int {
    ret := p + 1
    return ret
}

func main() {
    c := 4
    _ = g(c)
}

返回值ret是按值传递的,执行的是栈拷贝,不存在逃逸

  • 按址传递
package main

func g(p *int) *int {
    ret := *p + 1
    return &ret
}

func main() {
    c := new(int)
    *c = 4
    _ = g(c)
}

返回值&ret是按址传递,传递的是指针对象,发生了逃逸,将对象存放在堆上以便外部调用

# command-line-arguments
./test_stack.go:5:9: &ret escapes to heap
./test_stack.go:4:14: moved to heap: ret
./test_stack.go:3:17: g p does not escape
./test_stack.go:9:10: main new(int) does not escape

golang只有在function内的对象可能被外部访问时,才会把该对象分配在堆上

  • 在g()方法中,ret对象的引用被返回到了方法外,因此会发生逃逸;而p对象只在g()内被引用,不会发生逃逸
  • 在main()方法中,c对象虽然被g()方法引用了,但是由于引用的对象c没有在g()方法中发生逃逸,因此对象c的生命周期还是在main()中的,不会发生逃逸

2.3. 再看一个栗子

package main

type Ret struct {
    Data *int
}

func g(p *int) *Ret {
    var ret Ret
    ret.Data = p
    return &ret
}

func main() {
    c := new(int)
    *c = 4
    _ = g(c)
}

逃逸分析结果

# command-line-arguments
./test_stack.go:10:9: &ret escapes to heap
./test_stack.go:8:6: moved to heap: ret
./test_stack.go:7:17: leaking param: p to result ~r1 level=-1
./test_stack.go:14:10: new(int) escapes to heap
  • 可以看到,ret和2.2中一样,存在外部引用,发生了逃逸
  • 由于ret.Data是一个指针对象,p赋值给ret.Data后,也伴随p发生了逃逸
  • main()中的对象c,由于作为参数p传入g()后发生了逃逸,因此c也发生了逃逸
  • 当然,如果定义ret.Data为int(instead of *int)的话,对象p也是不会逃逸的(执行了拷贝)

3. 对开发者的一些建议

3.1. 大对象按址传递,小对象按值传递

  • 按址传递更高效,按值传递更安全(from William Kennedy)
  • 90%的bug都来自于指针调用

3.2. 初始化一个结构体,使用引用的方式来传递指针

func r() *Ret{
    var ret Ret
    ret.Data = ...
    ...
    return &ret
}

只有返回ret对象的引用时才会把对象分配在堆上,我们不必要在一开始的时候就显式地把ret定义为指针

ret = &Ret{}
...
return ret

对阅读代码也容易产生误导


参考链接:

results matching ""

    No results matching ""