变量逃逸一般发生在如下几种情况:
- 变量较大(栈空间不足)
- 变量大小不确定(如 slice 长度或容量不定)
- 返回地址
- 返回引用(引用变量的底层是指针)
- 返回值类型不确定(不能确定大小)
- 闭包
- 其他
知道变量逃逸的原因后,我们可以有意识地避免变量发生逃逸,将其限制在栈上,减少堆变量的分配,降低 GC 成本,可提高程序性能。
1.局部切片尽可能确定长度或容量
如果使用局部切片,已知切片的长度或容量,请使用常量或数值字面量来定义。
package main
func main() {
number := 10
s1 := make([]int, 0, number)
for i := 0; i < number; i++ {
s1 = append(s1, i)
}
s2 := make([]int, 0, 10)
for i := 0; i < 10; i++ {
s2 = append(s2, i)
}
}
我们来看一下编译器编译时对上面两个切片的优化决策。
go build -gcflags="-m -m -l" main.go
# command-line-arguments
./main.go:5:12: make([]int, 0, number) escapes to heap:
./main.go:5:12: flow: {heap} = &{storage for make([]int, 0, number)}:
./main.go:5:12: from make([]int, 0, number) (non-constant size) at ./main.go:5:12
./main.go:5:12: make([]int, 0, number) escapes to heap
./main.go:9:12: make([]int, 0, 10) does not escape
从输出结果可以看到,使用变量(非常量)来指定切片容量,会导致切片发生逃逸,影响性能。指定切片长度时也是一样的,尽可能使用常量或数值字面量。
下面看下二者的性能差异。
// sliceEscape 发生逃逸,在堆上申请切片
func sliceEscape() {
number := 10
s1 := make([]int, 0, number)
for i := 0; i < number; i++ {
s1 = append(s1, i)
}
}
// sliceNoEscape 不逃逸,限制在栈上
func sliceNoEscape() {
s1 := make([]int, 0, 10)
for i := 0; i < 10; i++ {
s1 = append(s1, i)
}
}
func BenchmarkSliceEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
sliceEscape()
}
}
func BenchmarkSliceNoEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
sliceNoEscape()
}
}
运行上面的基准测试结果如下:
go test -bench=BenchmarkSlice -benchmem main/copy
goos: darwin
goarch: amd64
pkg: main/copy
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkSliceEscape-12 43268738 27.40 ns/op 80 B/op 1 allocs/op
BenchmarkSliceNoEscape-12 186127288 6.454 ns/op 0 B/op 0 allocs/op
PASS
ok main/copy 4.402s
2.返回值 VS 返回指针
值传递会拷贝整个对象,而指针传递只会拷贝地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,返回指针导致的 GC 开销可能会严重影响性能。
一般情况下,对于需要修改原对象,或占用内存比较大的对象,返回指针。对于只读或占用内存较小的对象,返回值能够获得更好的性能。
下面以一个简单的示例来看下二者的性能差异。
type St struct {
arr [1024]int
}
func retValue() St {
var st St
return st
}
func retPtr() *St {
var st St
return &st
}
func BenchmarkRetValue(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = retValue()
}
}
func BenchmarkRetPtr(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = retPtr()
}
}
基准测试结果如下:
go test -gcflags="-l" -bench=BenchmarkRet -benchmem main/copy
goos: darwin
goarch: amd64
pkg: main/copy
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkRetValue-12 5194722 216.2 ns/op 0 B/op 0 allocs/op
BenchmarkRetPtr-12 1342947 893.6 ns/op 8192 B/op 1 allocs/op
PASS
ok main/copy 3.865s
3.小的拷贝好过引用
小的拷贝好过引用,什么意思呢,就是尽量使用栈变量而不是堆变量。
下面举一个反常识的例子,来证明小的拷贝比在堆上创建引用变量要好。
我们都知道 Go 里面的 Array 以 pass-by-value 方式传递后,再加上其长度不可扩展,考虑到性能我们一般很少使用它。实际上,凡事无绝对。有时使用数组进行拷贝传递,比使用切片要好。
// copy/copy.go
const capacity = 1024
func arrayFibonacci() [capacity]int {
var d [capacity]int
for i := 0; i < len(d); i++ {
if i <= 1 {
d[i] = 1
continue
}
d[i] = d[i-1] + d[i-2]
}
return d
}
func sliceFibonacci() []int {
d := make([]int, capacity)
for i := 0; i < len(d); i++ {
if i <= 1 {
d[i] = 1
continue
}
d[i] = d[i-1] + d[i-2]
}
return d
}
下面看一下性能对比。
func BenchmarkArray(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = arrayFibonacci()
}
}
func BenchmarkSlice(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = sliceFibonacci()
}
}
运行上面的基准测试,将得到如下结果。
go test -bench=. -benchmem -gcflags="-l" main/copy
goos: darwin
goarch: amd64
pkg: main/copy
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkArray-12 692400 1708 ns/op 0 B/op 0 allocs/op
BenchmarkSlice-12 464974 2242 ns/op 8192 B/op 1 allocs/op
PASS
ok main/copy 3.908s
从测试结果可以看出,对数组的拷贝性能却比使用切片要好。为什么会这样呢?
sliceFibonacci()
函数中分配的局部变量切片因为要返回到函数外部,所以发生了逃逸,需要在堆上申请内存空间。从测试也过也可以看出,arrayFibonacci()
函数没有内存分配,完全在栈上完成数组的创建。这里说明了对于一些短小的对象,栈上复制的成本远小于在堆上分配和回收的成本。
需要注意,运行上面基准测试时,传递了禁止内联的编译选项 "-l",如果发生内联,那么将不会出现变量的逃逸,就不存在堆上分配内存与回收的操作了,二者将看不出性能差异。
编译时可以借助选项-gcflags=-m
查看编译器对上面两个函数的优化决策。
go build -gcflags=-m copy/copy.go
# command-line-arguments
copy/copy.go:5:6: can inline arrayFibonacci
copy/copy.go:17:6: can inline sliceFibonacci
copy/copy.go:18:11: make([]int, capacity) escapes to heap
可以看到,arrayFibonacci() 和 sliceFibonacci() 函数均可内联。sliceFibonacci() 函数中定义的局部变量切片逃逸到了堆。
那么多大的变量才算是小变量呢? 对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上,不同 Go 版本的大小限制可能不一样。一般是 < 64KB,局部变量将不会逃逸到堆上。
4.返回值使用确定的类型
如果变量类型不确定,那么将会逃逸到堆上。所以,函数返回值如果能确定的类型,就不要使用 interface{}。
我们还是以上面斐波那契数列函数为例,看下返回值为确定类型和 interface{} 的性能差别。
const capacity = 1024
func arrayFibonacci() [capacity]int {
var d [capacity]int
for i := 0; i < len(d); i++ {
if i <= 1 {
d[i] = 1
continue
}
d[i] = d[i-1] + d[i-2]
}
return d
}
func arrayFibonacciIfc() interface{} {
var d [capacity]int
for i := 0; i < len(d); i++ {
if i <= 1 {
d[i] = 1
continue
}
d[i] = d[i-1] + d[i-2]
}
return d
}
func BenchmarkArray(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = arrayFibonacci()
}
}
func BenchmarkIfc(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = arrayFibonacciIfc()
}
}
运行上面的基准测试结果如下:
go test -bench=. -benchmem main/copy
goos: darwin
goarch: amd64
pkg: main/copy
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkArray-12 832418 1427 ns/op 0 B/op 0 allocs/op
BenchmarkIfc-12 380626 2861 ns/op 8192 B/op 1 allocs/op
PASS
ok main/copy 3.742s
可见,函数返回值使用 interface{} 返回时,编译器无法确定返回值的具体类型,导致返回值逃逸到堆上。当发生了堆上内存的申请与回收时,性能会差一点。
5.小结
栈上分配内存比在堆中分配内存有更高的效率。因为栈上分配的内存不需要 GC 处理,函数返回后就会直接释放,而堆上分配的内存使用完毕会交给 GC 处理。在知道常见的变量逃逸场景后,我们在编码时可以有意识地避免变量发生逃逸,尽可能地使用栈空间,而非堆空间。
以上仅列出了部分变量发生逃逸的情形。实际上,Go 编译器对变量的逃逸分析决策远比我们想像的要复杂。我们只能尽可能地去勾勒而无以绘其全貌。Go 官方也在 Frequently Asked Questions (FAQ) 明确地告诉我们,我们并不需要知道变量是分配在栈(stack)上还是堆(heap)上。Golang 中的变量只要被引用就一直会存活,存储在堆上还是栈上由 Go 内部实现决定而和具体的语法没有关系。