变量逃逸一般发生在如下几种情况:

  • 变量较大(栈空间不足)
  • 变量大小不确定(如 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 内部实现决定而和具体的语法没有关系。

powered by Gitbook该文章修订时间: 2024-08-04 07:27:05

results matching ""

    No results matching ""