golang的fmt包引发的变量逃逸到堆的问题

2,413 阅读4分钟

问题:

fmt.Printf等函数会导致传进去的参数在编译时从栈逃逸到堆上?

golang的issue:(github.com/golang/go/i…

磨刀(逃逸分析工具):

分析工具:

  • 1.通过编译工具查看详细的逃逸分析过程(go build -gcflags '-m -l' main.go)
  • 2.通过反编译命令查看go tool compile -S main.go

其中 编译参数(-gcflags)介绍:

  • -N: 禁止编译优化

  • -l: 禁止内联(可以有效减少程序大小)

  • -m: 逃逸分析(最多可重复四次)

  • -benchmem: 压测时打印内存分配统计

练功(实验):

Example:

package main

import (
    "fmt"
    "runtime"
)
type obj struct{}
func main() {
    a := &obj{}
    fmt.Printf("%p\n", a)
    b := &obj{}
  	println(b)
}

逃逸分析:

./main.go:17:7: &obj literal escapes to heap
./main.go:18:12: ... argument does not escape
./main.go:20:7: &obj literal does not escape
0x11a6c10
0xc000072f1f

可以看到我们的变量a因为fmt的函数逃逸到了堆上。

干仗(尝试验证问题):

首先声明,我们只是验证了这个问题的发生,但并没有解决这个问题,有想法的同学可以直接去提交m

目前网上的解释有2个方向:

fmt.Printf等函数会导致传进去的参数在编译时从栈逃逸到堆上

第一个罪犯:

其实,fmt.Printf 的第二个参数,是一个 interface 类型,在底层的调用中用到了断言,具体的调用逻辑是:

Printf->Fprintf->doPrintf->reflect.TypeOf(arg).Kind()

这里有人通过模拟fmt包得出了初步的结论(reusee.github.io/post/escape…

所以我们可以认为a在编译阶段,编译器无法确定其具体的类型。因此会产生逃逸,最终分配到堆上(最本质的原因是interface{}类型一般情况下底层会进行reflect,而使用的reflect.TypeOf(arg).Kind()获取接口类型对象的底层数据类型时发生了堆逃逸,最终就会反映为当入参是空接口类型时发生了逃逸)。

但不是说往func(interface{})传值,或者往func(*struct)传指针就会导致逃逸分析。只是大多数场景下,其内部都会用到反射,导致逃逸(switch type不会导致逃逸)。

验证:

package main

import (
    "fmt"
    "runtime"
)
type obj struct{}
func main() {
    a := &obj{}
    fmt.Printf("%p\n", a)
    b := &obj{}
  	reflect.TypeOf(b).Kind()
		println(b)
}

逃逸分析:

# command-line-arguments
./main.go:20:7: &obj literal escapes to heap
./main.go:21:12: ... argument does not escape
./main.go:23:7: &obj literal escapes to heap
0x11a6c30
0x11a6c30

可以发现两个变量都到了堆上,至于地址为什么一摸一样,可以关注我的另一篇文章:www.jianshu.com/p/e0fd84a59…

第二个罪犯:

我们点进去看看fmt.Printf的源码,同样的排查链路,Printf->Fprintf->doPrintf->printArg

我们发现有这么一段赋值代码,我们传入的u被赋值给了pp指针的一个成员变量:

func (p *pp) printArg(arg interface{}, verb rune) {
      p.arg = arg
      p.value = reflect.Value{} 
      ...
}

而这个pp类型的指针p是由构造函数newPrinter返回的,所以他的生命周期就变了,p一定发生逃逸,而p引用了传入指针u,经测试是逃逸了。

验证

package main
import (
	"fmt"
)
type obj struct{}
type pointer struct {
	o *obj
}
func main() {
	a := &obj{}
	fmt.Printf("%p\n", a)
	b := &obj{}
	p := newPrinter()
	p.o = b
	println(b)
}
func newPrinter() *pointer {
	return new(pointer)
}

结论:

# command-line-arguments
./main.go:23:12: new(pointer) escapes to heap
./main.go:14:7: &obj literal escapes to heap   // a
./main.go:15:12: ... argument does not escape
./main.go:16:7: &obj literal escapes to heap   // b
0x11a6c10
0x11a6c10

我们看到被p引用的b也被赶到了堆上

收功(总结):

fmt.Printf等函数传入参数会发生堆逃逸。

虽然日常的开发,go已经帮我们处理了编译前的内存分配,我们也不需要关注堆栈的使用情况,但有意识的避免堆逃逸可以有效的提高负担重的服务性能。堆逃逸在go中并不罕见,并且对gc的影响带来的性能消耗也是不容小觑的。

日常经常会碰到的:

1.函数返回指向栈内对象的指针,或者说是参数泄漏,延长了指针对象的生命周期。

2.调用反射(未知类型)(fmt案例的第一个问题)。

3.被已经逃逸的变量引用的指针,一定发生逃逸(fmt案例的第二个问题)。

4.被指针类型的slice、map和chan引用的指针,一定发生逃逸。

避免逃逸的好处:
  • 1.减少gc的压力,不逃逸的对象分配在栈上,当函数返回时就回收了资源,不需要gc标记清除

  • 2.逃逸分析完后可以确定哪些变量可以分配在栈上,栈的分配比堆快,性能好(系统开销少)

  • 3.减少动态分配所造成的内存碎片

反思:

函数传递指针真的比传值效率高吗?

我们知道传递指针可以减少底层值的拷贝,可以提高效率,负担也比较小

但是当数据比较小且多的情况,由于指针传递经常会导致逃逸到堆上,会增加GC的负担,所以传递指针不一定是高效的。

example:

使用指针的chan比使用值的chan慢30%,使用指针的chan发生逃逸,gc拖慢了速度。

ps :stackoverflow.com/questions/4…