Go语言中结构体的栈分配和堆分配,以及它们与垃圾回收的关系

244
我在C风格的基于堆栈的编程和Python风格的基于堆栈的编程之间感到有些认知失调。在C中,自动变量存储在堆栈上,而分配的内存存储在堆上;而在Python中,仅有指向堆上对象的引用/指针存储在堆栈上。
据我所知,以下两个函数产生相同的输出:
func myFunction() (*MyStructType, error) {
    var chunk *MyStructType = new(HeaderChunk)

    ...

    return chunk, nil
}


func myFunction() (*MyStructType, error) {
    var chunk MyStructType

    ...

    return &chunk, nil
}
如果我用C语言编写,第一种方式会在堆上放置一个对象,而第二种方式会在栈上放置。第一种方法将返回指向堆的指针,而第二种方法将返回指向栈的指针,这个指针在函数返回时就会消失,这是不好的事情。
如果我用Python(或许多其他现代语言,除了C#)编写,第二种方法是不可能的。
我知道Go会垃圾收集这两个值,所以这两种形式都是可以的。
引用一下:
请注意,与C不同,返回局部变量的地址是完全可以的;与函数返回后相关联的存储器保留相应的变量。实际上,每次计算复合文字的地址都会分配一个新的实例,因此我们可以将最后两行结合起来。 http://golang.org/doc/effective_go.html#functions 但是这提出了一些问题。
在示例1中,结构体是在堆上声明的。那么示例2呢?它是以与C相同的方式在堆栈上声明还是也在堆上声明?
如果示例2在堆栈上声明,那么函数返回后它如何保持可用?
如果示例2实际上是在堆上声明的,那么结构体是按值传递而不是按引用传递的吗?在这种情况下指针的作用是什么?
6个回答

238
值得注意的是,在Go语言规范中并没有出现"stack"和"heap"这些词。您的问题中使用了"...is declared on the stack,"和"...declared on the heap,"这样的措辞,但要注意,Go语言的声明语法并没有涉及堆栈或堆内存。 这在技术上使得所有问题的答案都依赖于实现。当然,在实际情况下,每个goroutine都有自己的堆栈和堆,有些东西放在堆栈上,有些则放在堆上。在某些情况下,编译器会遵循严格的规则(例如"new总是在堆上分配内存"),而在其他情况下,编译器会执行"逃逸分析"来决定对象是否可以存在于堆栈上,或者必须在堆上分配。
在您的第二个示例中,逃逸分析将显示指向结构体的指针逃逸,因此编译器必须分配该结构体。但我认为在这种情况下,Go的当前实现遵循了一条严格的规则,即如果对结构体的任何部分取地址,则该结构体将被放到堆上。
对于第三个问题,我们可能会混淆术语。在Go中,一切都是按值传递,不存在按引用传递。在这里,您正在返回一个指针值。那么指针有什么作用呢?请考虑修改您示例的以下方式:
type MyStructType struct{}

func myFunction1() (*MyStructType, error) {
    var chunk *MyStructType = new(MyStructType)
    // ...
    return chunk, nil
}

func myFunction2() (MyStructType, error) {
    var chunk MyStructType
    // ...
    return chunk, nil
}

type bigStruct struct {
    lots [1e6]float64
}

func myFunction3() (bigStruct, error) {
    var chunk bigStruct
    // ...
    return chunk, nil
}

我修改了myFunction2,现在返回结构体而不是结构体的地址。现在比较一下myFunction1和myFunction2的汇编输出。

--- prog list "myFunction1" ---
0000 (s.go:5) TEXT    myFunction1+0(SB),$16-24
0001 (s.go:6) MOVQ    $type."".MyStructType+0(SB),(SP)
0002 (s.go:6) CALL    ,runtime.new+0(SB)
0003 (s.go:6) MOVQ    8(SP),AX
0004 (s.go:8) MOVQ    AX,.noname+0(FP)
0005 (s.go:8) MOVQ    $0,.noname+8(FP)
0006 (s.go:8) MOVQ    $0,.noname+16(FP)
0007 (s.go:8) RET     ,

--- prog list "myFunction2" ---
0008 (s.go:11) TEXT    myFunction2+0(SB),$0-16
0009 (s.go:12) LEAQ    chunk+0(SP),DI
0010 (s.go:12) MOVQ    $0,AX
0011 (s.go:14) LEAQ    .noname+0(FP),BX
0012 (s.go:14) LEAQ    chunk+0(SP),BX
0013 (s.go:14) MOVQ    $0,.noname+0(FP)
0014 (s.go:14) MOVQ    $0,.noname+8(FP)
0015 (s.go:14) RET     ,

不要担心,这里myFunction1的输出结果与peterSO(非常好的)答案中的不同。显然我们使用了不同的编译器。此外,请注意我已经修改了myFunction2将返回类型修改为myStructType而不是*myStructType。对runtime.new的调用已经被移除,这在某些情况下可能是一个好事情。不过,现在请看一下myFunction3。

--- prog list "myFunction3" ---
0016 (s.go:21) TEXT    myFunction3+0(SB),$8000000-8000016
0017 (s.go:22) LEAQ    chunk+-8000000(SP),DI
0018 (s.go:22) MOVQ    $0,AX
0019 (s.go:22) MOVQ    $1000000,CX
0020 (s.go:22) REP     ,
0021 (s.go:22) STOSQ   ,
0022 (s.go:24) LEAQ    chunk+-8000000(SP),SI
0023 (s.go:24) LEAQ    .noname+0(FP),DI
0024 (s.go:24) MOVQ    $1000000,CX
0025 (s.go:24) REP     ,
0026 (s.go:24) MOVSQ   ,
0027 (s.go:24) MOVQ    $0,.noname+8000000(FP)
0028 (s.go:24) MOVQ    $0,.noname+8000008(FP)
0029 (s.go:24) RET     ,

仍然没有调用runtime.new,但确实可以通过值返回一个8MB的对象。 这样做是可行的,但通常不建议这么做。指针在这里的作用是避免推动8MB的对象。


9
非常感谢。我并不是真的在问“指针到底有什么意义”,而更多的是在问“当值表现得像指针时,指针还有什么意义”,而你的回答已经让这种情况变得无关紧要了。 - Joe
38
请提供汇编的简要说明。 - ElefEnt
new 命令是否总是在堆上分配内存? - YenForYang
一开始回答非常好,但没能抓住要点。那么,在 这个 实现中,所有三个示例都使用堆?存在/缺失 runtime.new 有什么区别?此外,如果消除 runtime.new,有什么好处? - starriet

76
type MyStructType struct{}

func myFunction1() (*MyStructType, error) {
    var chunk *MyStructType = new(MyStructType)
    // ...
    return chunk, nil
}

func myFunction2() (*MyStructType, error) {
    var chunk MyStructType
    // ...
    return &chunk, nil
}

在这两种情况下,Go的当前实现都会为MyStructType类型的struct分配内存,并返回它的地址。 这两个函数是等价的;编译器汇编代码相同。

--- prog list "myFunction1" ---
0000 (temp.go:9) TEXT    myFunction1+0(SB),$8-12
0001 (temp.go:10) MOVL    $type."".MyStructType+0(SB),(SP)
0002 (temp.go:10) CALL    ,runtime.new+0(SB)
0003 (temp.go:10) MOVL    4(SP),BX
0004 (temp.go:12) MOVL    BX,.noname+0(FP)
0005 (temp.go:12) MOVL    $0,AX
0006 (temp.go:12) LEAL    .noname+4(FP),DI
0007 (temp.go:12) STOSL   ,
0008 (temp.go:12) STOSL   ,
0009 (temp.go:12) RET     ,

--- prog list "myFunction2" ---
0010 (temp.go:15) TEXT    myFunction2+0(SB),$8-12
0011 (temp.go:16) MOVL    $type."".MyStructType+0(SB),(SP)
0012 (temp.go:16) CALL    ,runtime.new+0(SB)
0013 (temp.go:16) MOVL    4(SP),BX
0014 (temp.go:18) MOVL    BX,.noname+0(FP)
0015 (temp.go:18) MOVL    $0,AX
0016 (temp.go:18) LEAL    .noname+4(FP),DI
0017 (temp.go:18) STOSL   ,
0018 (temp.go:18) STOSL   ,
0019 (temp.go:18) RET     ,

调用

在函数调用中,函数值和参数按照通常的顺序进行评估。在它们被评估之后,调用的参数按值传递给函数,然后被调用的函数开始执行。函数的返回参数也是通过值传递回调用函数的。

所有的函数和返回参数都是按值传递的。类型为*MyStructType的返回参数是一个地址。


非常感谢!已经点赞了,但是我接受Sonia的回答,因为其中提到了逃逸分析。 - Joe
1
PeterSo,你和@Sonia是如何生成那个汇编文件的?你们两个的格式都一样。我无论使用什么命令/标志,都无法生成它,已经尝试了objdump、go工具和otool。 - 10 cls
4
好的,明白了 - gcflags。 - 10 cls

41
根据 Go的FAQ 所述:
如果编译器无法证明变量在函数返回后不会被引用,那么编译器必须将变量分配到垃圾回收堆上,以避免悬空指针错误。

13

1
这里是关于堆栈和GC的另一篇讨论,来源于《Go垃圾回收指南》(A Guide to the Go Garbage Collector)Go值的存储位置
  • 堆栈分配
    • 在本地变量中存储的非指针 Go 值可能根本不会由 Go GC 管理,而是 Go 会安排分配与创建它的词法作用域相关联的内存。一般来说,这比依赖于 GC 更有效,因为 Go 编译器能够预先确定何时可以释放该内存并发出清除的机器指令。通常,我们将为 Go 值分配内存这样的操作称为“堆栈分配”,因为该空间存储在 goroutine 堆栈上。
  • 堆分配
    • 因为 Go 编译器无法确定其生命周期,所以无法通过此方式分配内存的 Go 值被称为“逃逸到堆”。 “堆”可以被视为内存分配的万能筐,用于存放 Go 值。在堆上分配内存的行为通常被称为“动态内存分配”,因为编译器和运行时都几乎不能做出有关如何使用此内存以及何时可以清理它的假设。这就是 GC 的作用:它是一个专门识别和清理动态内存分配的系统。
有许多原因导致 Go 值需要逃逸到堆上。其中一个原因可能是它的大小是动态确定的。例如,考虑一个切片的后备数组,其初始大小由变量而非常量确定。请注意,逃逸到堆上也必须是传递性的:如果对 Go 值的引用被写入已确定逃逸的另一个 Go 值中,那个值也必须逃逸。
逃逸分析
关于如何访问Go编译器的逃逸分析信息,最简单的方法是通过Go编译器支持的调试标志来描述在某个包中应用或未应用的所有优化的文本格式。这包括值是否逃逸。尝试下面的命令,其中[package]是某个Go包路径。
$ go build -gcflags=-m=3 [package]

实现特定的优化
Go语言的垃圾回收器对内存中活跃对象的结构敏感,因为复杂的对象和指针图形既限制了并行性,又增加了垃圾回收器的工作量。因此,垃圾回收器包含了一些针对特定常见结构的优化。下面列出了对性能优化最直接有用的几个。
- 不包含指针的值被与其他值分离。 因此,在不严格需要指针的数据结构中消除指针可能是有利的,因为这减少了GC对程序施加的缓存压力。因此,依赖于索引而非指针值的数据结构,虽然类型不够严谨,但可能表现更好。只有在清楚对象图形复杂且GC花费了大量时间进行标记和扫描时,才值得这样做。
- 垃圾回收器将在值中的最后一个指针处停止扫描。 因此,如果应用程序大部分时间都在进行标记和扫描,则将struct类型值中的指针字段分组放置在值的开头可能是有利的。 (理论上编译器可以自动完成这项工作,但尚未实现,struct字段按源代码中编写的方式排列。)

0
func Function1() (*MyStructType, error) {
    var chunk *MyStructType = new(HeaderChunk)

    ...

    return chunk, nil
}


func Function2() (*MyStructType, error) {
    var chunk MyStructType

    ...

    return &chunk, nil
}

Function1和Function2可能是内联函数。返回变量不会逃逸。在堆上分配变量并非必要。

我的示例代码:

   package main
   
   type S struct {
           x int
   }
   
   func main() {
           F1()
           F2()
          F3()
  }
  
  func F1() *S {
          s := new(S)
          return s
  }
  
  func F2() *S {
          s := S{x: 10}
          return &s
  }
  
  func F3() S {
          s := S{x: 9}
          return s
  }

根据 cmd 的输出:
go run -gcflags -m test.go

输出:

# command-line-arguments
./test.go:13:6: can inline F1
./test.go:18:6: can inline F2
./test.go:23:6: can inline F3
./test.go:7:6: can inline main
./test.go:8:4: inlining call to F1
./test.go:9:4: inlining call to F2
./test.go:10:4: inlining call to F3
/var/folders/nr/lxtqsz6x1x1gfbyp1p0jy4p00000gn/T/go-build333003258/b001/_gomod_.go:6:6: can inline init.0
./test.go:8:4: main new(S) does not escape
./test.go:9:4: main &s does not escape
./test.go:14:10: new(S) escapes to heap
./test.go:20:9: &s escapes to heap
./test.go:19:2: moved to heap: s

如果编译器足够聪明,F1() F2() F3() 可能不会被调用。因为这没有意义。

不必关心变量是在堆上还是栈上分配,只需使用它。如有必要,请通过互斥锁或通道进行保护。


1
你可以在函数前使用//go:noinline来防止内联以测试代码。问题实际上更多是概念上的澄清,以防编译器不选择内联。 - Mayank

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接