原创,本文实验环境 go1.21.0, GOOS='darwin'
你一定也有这样的疑惑,函数参数是struct或者返回值是struct的时候,什么时候用指针,什么时候用值传递,我遇到过无脑用指针的公司,也遇到过无脑用值传递的公司。(排除要修改参数内的值,必须使用指针的时候,我们讨论的是两者都可行,对程序效果无bug的情况下),本文专门从性能的角度来探讨一下这个知识点。
考虑到性能,不得不先说一下逃逸:
在程序中,每个函数块都会有自己的内存区域用来存自己的局部变量(内存占用少)、返回地址、返回值之类的数据,这一块内存区域有特定的结构和寻址方式,寻址起来十分迅速,开销很少。这一块内存地址称为栈。栈是线程级别的,大小在创建的时候已经确定,当变量太大的时候,会"逃逸"到堆上,这种现象称为内存逃逸。简单来说,局部变量通过堆分配和回收,就叫内存逃逸。
考虑如下的程序
package main
type T struct {
b [10*1024*1024]byte
}
func main() {
var arr T
fn(arr)
}
func fn(t T) {
}
编译,go build -gcflags='-m' ./main.go ,没有发生逃逸
如果将 T 的成员b数组长度+1,改成 10*1024*1024+1 ,再次编译,会发现,逃逸了:
我们可以看到,当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。
(当然,这只是逃逸的一种情况。其他诸如interface{}逃逸,不定长度slice逃逸,我们以后再说。)
那么它对性能有什么影响呢,我们接下来分别进行benchmark测试,本文讨论的是值传递和指针传递,所以先对这两种进行测试。分别讨论有逃逸和没逃逸情况下的值传递和指针传递的表现,
一、在没有逃逸发生的情况下:
package main
import "testing"
type T struct {
b [10 * 1024 * 1024]byte
}
func fnStack(t T) {}
func fnHeap(t *T) {}
func BenchmarkStack(b *testing.B) {
var t = T{}
for i := 0; i < b.N; i++ {
fnStack(t)
}
}
func BenchmarkHeap(b *testing.B) {
var s1 = &T{}
for i := 0; i < b.N; i++ {
fnHeap(s1)
}
}
结果如下,
很明显,两者无差距
二、改变结构体T的大小(b数组长度加1),有逃逸的情况下:
package main
import "testing"
type T struct {
b [10 * 1024 * 1024+1]byte
}
func fnStack(t T) {}
func fnHeap(t *T) {}
func BenchmarkStack(b *testing.B) {
var t = T{}
for i := 0; i < b.N; i++ {
fnStack(t)
}
}
func BenchmarkHeap(b *testing.B) {
var s1 = &T{}
for i := 0; i < b.N; i++ {
fnHeap(s1)
}
}
结果如下:
性能天壤之别。用值传递,要开辟新的内存来存储实参的值,但是又发生了逃逸,只好到堆上去开辟内存,对gc产生了压力。而指针传递省去了开辟新内存的过程,拷贝的过程。给大家看一下值传递发生逃逸下的gc情况:代码:
func BenchmarkStackTrace(b *testing.B) {
f, err := os.Create("stack.out")
if err != nil {
panic(err)
}
defer f.Close()
err = trace.Start(f)
if err != nil {
panic(err)
}
var t = T{}
for i := 0; i < b.N; i++ {
fnStack(t)
}
trace.Stop()
b.StopTimer()
}
结果
差不多每5ms触发一次gc
结论就是:小对象还是尽量栈,只要不逃逸,如果很大的会导致逃逸,那还是直接指针性能更好,具体情况还是得测啊