• Go高级编程-逃逸分析
  • 发布于 2个月前
  • 228 热度
    0 评论
逃逸分析
Go 语言的编译器使用 逃逸分析 决定哪些变量分配在栈上,哪些变量分配在堆上。在栈上分配和回收内存很快,只需要 2 个指令: PUSH + POP, 也就是仅需要将数据复制到内存的时间,而堆上分配和回收内存,一个相当大的开销是 GC。

特性
• 指向 栈 对象的指针不能分配堆上 (避免悬挂指针)
• 指向 栈 对象的指针在对象销毁时必须被同时销毁 (避免悬挂指针和内存泄露)
例如对于函数内部的变量来说,不论是否通过 new 函数创建,最后会被分配在 堆 还是 栈,是由编译器使用 逃逸分析 之后决定的。具体来说,当发现变量的作用域没有超出函数范围,分配在 栈 上,反之则必须分配在 堆 上,也就是说: 如果函数外部没有引用,则优先分配在 栈 上, 如果函数外部存在引用,则必须分配在 堆 上。所以,闭包必然会发生逃逸。

发生场景
• 变量占用内存过大 (如大的结构体)
• 变量占用内存不确定 (如 链表, slice 导致的扩容)
• 变量类型不确定 (interface{})
• 指针类型
• 函数返回变量地址 (如一个结构体地址)
• 闭包
• interface
过多的变量逃逸到堆上,会增加 GC 成本,我们可以通过控制变量的分配方式,尽可能地降低 GC 成本,提高性能。

分析命令
• 使用 go 命令
$ go tool compile -m main.go
# 或者
$ go build -gcflags='-m -l' main.go
• 反汇编
$ go tool compile -S main.go

示例
确定的数据类型和 interface{}
如果变量类型是 interface,那么将会 逃逸 到堆上。函数返回值应尽量使用确定的数据类型,避免使用 interface{}。
package main

func main() {
    data := []interface{}{100, 200}
    data[0] = 100
}
$ go tool compile -m main.go
 输出如下
main.go:3:6: can inline main
main.go:4:23: []interface {}{...} does not escape
main.go:4:24: 100 does not escape // 未发生逃逸
main.go:4:29: 200 does not escape // 未发生逃逸
main.go:5:2: 100 escapes to heap  // 发生逃逸

基准测试
这里以使用 interface{} 触发逃逸作为例子进行分析。
使用 interface{}
package performance
import "testing"
const size = 1024
// 堆代码 duidaima.com
func genSeqNumbers() interface{} {
    var res [size]int
    for i := 0; i < len(res); i++ {
        if i <= 1 {
            res[i] = 1
            continue
        }
        res[i] = res[i-1] + res[i-2]
    }
    return res
}

func Benchmark_Compare(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = genSeqNumbers()
    }
}
运行测试,并将基准测试结果写入文件:
# 运行 100000 次, 统计内存分配
$ go test -run='^$' -bench=. -count=1 -benchtime=100000x -benchmem > slow.txt

使用确定的数据类型
package performance

import "testing"

const size = 1024

func genSeqNumbers() [1024]int {
    var res [size]int
    for i := 0; i < len(res); i++ {
        if i <= 1 {
            res[i] = 1
            continue
        }
        res[i] = res[i-1] + res[i-2]
    }
    return res
}

func Benchmark_Compare(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = genSeqNumbers()
    }
}
运行测试,并将基准测试结果写入文件:
# 运行 100000 次, 统计内存分配
$ go test -run='^$' -bench=. -count=1 -benchtime=100000x -benchmem > fast.txt
使用 benchstat 比较差异
$ benchstat -alpha=100 fast.txt slow.txt
 输出如下
name        old time/op    new time/op    delta
_Compare-8    1.44µs ± 0%    3.22µs ± 0%  +123.40%  (p=1.000 n=1+1)

name        old alloc/op   new alloc/op   delta
_Compare-8     0.00B       8192.00B ± 0%     +Inf%  (p=1.000 n=1+1)

name        old allocs/op  new allocs/op  delta
_Compare-8      0.00           1.00 ± 0%     +Inf%  (p=1.000 n=1+1)
从输出的结果中可以看到,通过使用确定的数据类型,运行时间提升了 1 倍多, 内存分配量和内存分配次数降为 0。高性能 Tips: 在 hot path 上要尽可能返回具体的数据类型。

其他逃逸场景
接下来介绍几种常见的逃逸场景。

切片
当切片占用内存超过一定大小,或无法确定切片长度时,对象将分配在 堆 上。

逃逸场景
package main

func main() {
    weekdays := 7
    data := make(map[string][]int)
    data["weeks"] = make([]int, weekdays)
    data["weeks"] = append(data["weeks"], []int{0, 1, 2, 3, 4, 5, 6}...)
}
$ go tool compile -m main.go
 输出如下 (发生逃逸)
main.go:3:6: can inline main
main.go:5:14: make(map[string][]int) does not escape
main.go:6:22: make([]int, weekdays) escapes to heap
main.go:7:45: []int{...} does not escape
避免逃逸方案
如果切片容量较小,可以改为使用数组,避免发生逃逸,详情见 [切片和数组性能差异]。
package main

func main() {
    data := make(map[string][7]int)
    data["weeks"] = [...]int{0, 1, 2, 3, 4, 5, 6}
}
$ go tool compile -m main.go
 输出如下 (没有发生逃逸)
main.go:3:6: can inline main
main.go:4:14: make(map[string][7]int) does not escape
指针
逃逸场景
package main

func main() {
    n := 10
    data := make([]*int, 1)
    data[0] = &n
}
$ go tool compile -m main.go
 输出如下 (发生逃逸)
main.go:3:6: can inline main
main.go:4:2: moved to heap: n
main.go:5:14: make([]*int, 1) does not escape
interface{}
逃逸场景
package main

func main() {
    data := make(map[interface{}]interface{})
    data[100] = 200
}
$ go tool compile -m main.go
输出如下 (发生逃逸)
main.go:3:6: can inline main
main.go:4:14: make(map[interface {}]interface {}) does not escape
main.go:5:2: 100 escapes to heap
main.go:5:2: 200 escapes to heap

函数返回值
这应该是比较常见的一种情况,在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在, 对象的内存不能随着函数结束而回收,因此只能分配在 堆 上。

逃逸场景
package main
import "math/rand"
func foo(argVal int) *int {
    var fooVal1 = 11
    var fooVal2 = 12
    var fooVal3 = 13
    var fooVal4 = 14
    var fooVal5 = 15
    // 堆代码 duidaima.com
    // 循环是防止编译器将 foo 函数优化为 inline
    // 如果不用随机数指定循环次数,也可能被编译器优化为 inline
    // 如果是内联函数,main 调用 foo 将是原地展开
    //    那么 fooVal1 ... fooVal5 相当于 main 作用域的变量
    //    即使 fooVal3 发生逃逸,地址与其他几个变量也是连续的
    n := rand.Intn(5) + 1
    for i := 0; i < n; i++ {
        println(&argVal, &fooVal1, &fooVal2, &fooVal3, &fooVal4, &fooVal5)
    }

    return &fooVal3
}

func main() {
    mainVal := foo(1)
    println(*mainVal, mainVal)
}
运行代码
$ go run main.go
输出如下  (发生逃逸)
0xc000114f58 0xc000114f38 0xc000114f30 0xc000120000 0xc000114f28 0xc000114f20
0xc000114f58 0xc000114f38 0xc000114f30 0xc000120000 0xc000114f28 0xc000114f20
13 0xc000120000
通过输出的结果可以看到,变量 fooVal3 的地址明显与其他变量地址不是连续的。

结果分析

$ go tool compile -m main.go

 输出如下 (发生逃逸)
main.go:16:16: inlining call to rand.Intn
main.go:24:6: can inline main
main.go:8:6: **moved to heap: fooVal3**

# 查看逃逸分析详情
$ go build -gcflags='-m -l' main.go

# 输出如下 (发生逃逸)
./main.go:5:6: cannot inline foo: function too complex: cost 124 exceeds budget 80
...
./main.go:8:6: fooVal3 escapes to heap:
...
./main.go:8:6: **moved to heap: fooVal3**

# 或者
$ go tool compile -S main.go | grep "runtime.newobject"

# 输出如下
0x0036 00054 (**main.go:8**)        CALL    runtime.newobject(SB)
rel 55+4 t=7 runtime.newobject+0

# main.go 第 8 行正好是 var fooVal3 = 13
通道
逃逸场景
package main

func main() {
    ch := make(chan string)

    s := "hello world"

    go func() {
        ch <- s
    }()

    <-ch
}
$ go tool compile -m main.go

# 输出如下 (发生逃逸)
main.go:19:5: can inline main.func1
main.go:19:5: func literal escapes to heap
闭包
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。简单来说,闭包可以在一个函数内部访问到其外部函数的作用域。

逃逸场景
package main

func inc() func() int {
    n := 0
    return func() int {
        n++
        return n
    }
}

func main() {
    in := inc()
    println(in()) // 1
    println(in()) // 2
}
$ go tool compile -m main.go

# 输出如下 (发生逃逸)
main.go:3:6: can inline inc
main.go:5:9: can inline inc.func1
main.go:12:11: inlining call to inc
main.go:5:9: can inline main.func1
main.go:13:12: inlining call to main.func1
main.go:14:12: inlining call to main.func1
main.go:4:2: **moved to heap: n**
main.go:5:9: func literal escapes to heap
main.go:12:11: func literal does not escape
inc() 函数返回一个闭包函数,闭包函数内部访问了外部变量 n, 形成了引用关系,那么外部变量 n 将会一直存在,直到 inc() 函数被销毁, 所以最终外部变量 n 被分配到了 堆 上。

占用过大空间
操作系统对内核线程使用的栈空间是有大小限制的,64 位系统上通常是 8 MB。
# 查看系统栈内存大小
$ ulimit -s
# 8192
因为栈空间通常比较小,因此递归函数实现不当时,容易导致栈溢出。对于 Go 语言来说,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间, goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。对 Go 编译器而言, 超过一定大小的局部变量将逃逸到 堆 上,不同的 Go 版本的大小限制可能不一样。

逃逸场景
package main

import "math/rand"

func generate8192() {
    nums := make([]int, 8192) // = 64KB
    for i := 0; i < 8192; i++ {
        nums[i] = rand.Int()
    }
}

func generate8193() {
    nums := make([]int, 8193) // < 64KB
    for i := 0; i < 8193; i++ {
        nums[i] = rand.Int()
    }
}

func generate(n int) {
    nums := make([]int, n) // 不确定大小
    for i := 0; i < n; i++ {
        nums[i] = rand.Int()
    }
}

func main() {
    generate8192()
    generate8193()
    generate(1)
}
$ go tool compile -m main.go

# 输出如下 (发生逃逸)
main.go:6:14: make([]int, 8192) does not escape
main.go:13:14: make([]int, 8193) escapes to heap
main.go:20:14: make([]int, n) escapes to heap
从输出的结果中可以看到,make([]int, 8192) 没有发生逃逸,make([]int, 8193) 和 make([]int, n) 逃逸到 堆 上, 说明当切片占用内存超过一定大小,或无法确定当前切片长度时,对象将分配到 堆 上。
用户评论