Golang逃逸分析之fmt.Println

994 阅读7分钟

前言

fmt 系列的打印函数会让参数“逃逸”,这个我们应该都知道。

关于逃逸分析原理前面有写过一篇文章:juejin.cn/post/705517… 但是没有分析形如 fmt.Println() 让参数逃逸这种情况,还是觉得怅然若失。经过不断的试错和探索,终于看清了 fmt 系列函数参数逃逸的真面目。为什么这些函数会导致参数逃逸?参数真的逃逸了吗?逃逸的原理是啥?本篇文章会一一讲解。

为什么 fmt 打印会导致参数逃逸

因为 fmt 包下很多打印函数都会导致参数逃逸,比如 fmt.Printf,fmt.Println 等等,下面统一用 fmt.func 代替这些函数。

为什么 fmt.func 会导致参数逃逸?那肯定是 fmt.func 的实现中有让 interface 参数逃逸的逻辑,然后传进去的变量才会逃逸。

fmt.func 实现逻辑中有好几处地方是会导致 interface 参数逃逸的:

第一处:

reflect.TypeOf(arg).Kind()

为了确认 interface{} 类型,一般情况下底层会进行reflect,而使用的reflect.TypeOf(arg).Kind()获取接口类型对象的底层数据类型时发生了堆逃逸。可以简单理解为编译器无法确定其具体的类型,因此会产生逃逸,最终分配到堆上。

第二处:

printArg 函数在 fmt 包中被大量使用,其中有这么一段逻辑:

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

我们找到这个 pp 结构体:

// pp is used to store a printer's state and is reused with sync.Pool to avoid allocations.
type pp struct {
    //...
}

可知 pp 是放在 sync.Pool 中以复用。那么语句 p.arg = arg 将会使 arg 参数逃逸(更准确的说是 arg 底下的 unsafe.Pointer 字段逃逸),因为逃逸分析不允许堆对象指向栈对象。

第三处:

reflect.ValueOf(arg)

reflect.ValueOf 也会使对象逃逸,看实现就知道:

func ValueOf(i interface{}) Value {
   if i == nil {
      return Value{}
   }
   escapes(i)
   return unpackEface(i)
}

好了,导致 fmt.func 入参逃逸的几个怀疑点都找到了,下面分析对象传进 fmt.func 的逃逸行为。

抛出疑问

先来看一个例子,例子来自 TonyBai 大佬博客:tonybai.com/2021/05/24/…

1  package main
2
3  import "fmt"
4
5  func foo() {
6     var a int = 1
7     var b int = 2
8     fmt.Printf("a = %d\n", a)
9     println("addr of a in foo =", &a)
10    println("addr of b in foo =", &b)
11 }
12 
13 func main() {
14    foo()
15 }

注:println和print两个预定义函数并没有像fmt.Printf系列函数的“副作用”,不会影响变量的逃逸性。所以这里使用println来输出变量的实际分配内存地址。

我们来对上面的代码运行逃逸分析:

D:\workspace\mypro\main>go build -gcflags="-m -l" main.go
# command-line-arguments
.\main.go:8:12: ... argument does not escape
.\main.go:8:13: a escapes to heap

我们看到逃逸分析输出第 8 行的变量 “a escapes to heap”,不过这个“逃逸”有些奇怪,因为按照之前的经验,如果某个变量真实逃逸了,那么逃逸分析会在其声明的那行输出:“moved to heap: xx” 字样。 如下列示例代码:

1  package main
2  
3  func main() {
4     compare()
5  }
6
7  func compare() *int {
8     a:=1
9     return &a
10 }

逃逸结果打印:

D:\workspace\mypro\main>go build -gcflags="-m -l" main.go
# command-line-arguments
.\main.go:8:2: moved to heap: a

看到没,如果变量真的逃逸了,会在变量声明那一行输出 “moved to heap: a” 。

而上面这个 .\main.go:8:13: a escapes to heap 输出既不是在变量声明的那一行,也没有输出 “moved to heap: a” 字样,变量 a 真的逃逸了么?

代码打印结果:

a = 1
addr of a in foo = 0xc00009df50
addr of b in foo = 0xc00009df48

我们看到变量a的地址与未逃逸的变量b的地址都在同一个栈空间,那说明变量 a 并未逃逸

那问题来了,这句 .\main.go:8:13: a escapes to heap 中的“逃逸”是指什么?

我们知道 fmt.Printf 的入参是 interface 类型,所以 a 要传进 fmt.Printf 首先要转换成 interface 类型才可以。那这个“逃逸”是否代表着在进入 Printf 函数时,a 转换为 inerface 的过程中发生的呢?

我们知道 interface 包含两种类型:iface 和 efaceiface 和 eface 都是 Go 中描述 interface 的底层结构体,区别在于 iface 描述的接口包含方法,而 eface 则是不包含任何方法的空接口:interface{}

type iface struct {
    tab  *itab
    data unsafe.Pointer
}

type eface struct {
    _type *_type
    data  unsafe.Pointer
}

type itab struct {
    inter  *interfacetype
    _type  *_type
    link   *itab
    hash   uint32 // copy of _type.hash. Used for type switches.
    bad    bool   // type does not implement interface
    inhash bool   // has this itab been added to hash?
    unused [2]byte
    fun    [1]uintptr // variable sized
}
  • iface 有两个字段,tab 指向一个 itab 实体, 它表示接口的类型以及赋给这个接口的对象类型还有一些其他信息。data 则指向接口具体的值,一般而言是一个指向堆内存的指针。
  • eface 有两个字段,其中 _type 字段表示空接口所承载的具体的对象类型。data 和 iface 的一样。

如果一个变量要转换为空接口类型,那肯定是转换为 eface 而不是 iface 。很显然,上面代码中 a 就是转变为 eface。

汇编分析

汇编之下无秘密不是开玩笑的。

重新写个简单示例:

1  package main
2 
3  import "fmt"
4 
5  func foo() {
6     a:=1
7     fmt.Println(a)
8  }

精简后汇编结果如下:

D:\workspace\mypro\main>go tool compile -N -S -l main.go
"".foo STEXT size=216 args=0x0 locals=0x78 funcid=0x0
        0x0000 00000 (main.go:5)        TEXT    "".foo(SB), ABIInternal, $120-0
        //保存BP寄存器和其他无关部分省略...
        0x0028 00040 (main.go:6)        MOVQ    $1, "".a+48(SP)
        0x0031 00049 (main.go:7)        XORPS   X0, X0
        0x0034 00052 (main.go:7)        MOVUPS  X0, ""..autotmp_1+72(SP)
        0x0039 00057 (main.go:7)        LEAQ    ""..autotmp_1+72(SP), AX
        0x003e 00062 (main.go:7)        MOVQ    AX, ""..autotmp_3+64(SP)
        0x0043 00067 (main.go:7)        MOVQ    "".a+48(SP), AX
        0x0048 00072 (main.go:7)        MOVQ    AX, (SP)
        0x004c 00076 (main.go:7)        PCDATA  $1, $1
        0x004c 00076 (main.go:7)        CALL    runtime.convT64(SB)
        0x0051 00081 (main.go:7)        MOVQ    8(SP), AX
        0x0056 00086 (main.go:7)        MOVQ    AX, ""..autotmp_4+56(SP)
        0x005b 00091 (main.go:7)        MOVQ    ""..autotmp_3+64(SP), CX
        0x0060 00096 (main.go:7)        TESTB   AL, (CX)
        0x0062 00098 (main.go:7)        LEAQ    type.int(SB), DX
        0x0069 00105 (main.go:7)        MOVQ    DX, (CX)
        0x006c 00108 (main.go:7)        LEAQ    8(CX), DI
        0x0070 00112 (main.go:7)        PCDATA  $0, $-2
        0x0070 00112 (main.go:7)        CMPL    runtime.writeBarrier(SB), $0
        0x0077 00119 (main.go:7)        JEQ     123
        0x0079 00121 (main.go:7)        JMP     199
        0x007b 00123 (main.go:7)        MOVQ    AX, 8(CX)
        0x007f 00127 (main.go:7)        NOP
        0x0080 00128 (main.go:7)        JMP     130
        0x0082 00130 (main.go:7)        PCDATA  $0, $-1
        0x0082 00130 (main.go:7)        MOVQ    ""..autotmp_3+64(SP), AX
        0x0087 00135 (main.go:7)        TESTB   AL, (AX)
        0x0089 00137 (main.go:7)        JMP     139
        0x008b 00139 (main.go:7)        MOVQ    AX, ""..autotmp_2+88(SP)
        0x0090 00144 (main.go:7)        MOVQ    $1, ""..autotmp_2+96(SP)
        0x0099 00153 (main.go:7)        MOVQ    $1, ""..autotmp_2+104(SP)
        0x00a2 00162 (main.go:7)        MOVQ    AX, (SP)
        0x00a6 00166 (main.go:7)        MOVQ    $1, 8(SP)
        0x00af 00175 (main.go:7)        MOVQ    $1, 16(SP)
        0x00b8 00184 (main.go:7)        PCDATA  $1, $0
        0x00b8 00184 (main.go:7)        CALL    fmt.Println(SB)
        0x00bd 00189 (main.go:8)        MOVQ    112(SP), BP
        0x00c2 00194 (main.go:8)        ADDQ    $120, SP
        0x00c6 00198 (main.go:8)        RET

注意,我用的是go 1.16,还是栈传参,如果用的是go 1.17,已经改成寄存器传参了,可能打印结果稍微有点不一样。

汇编解读:

  1. 把常数 1 放进 SP 寄存器往上数 48 个字节的位置,对应着第六行的操作 a:=1
MOVQ    $1, "".a+48(SP)
  1. 把 72(SP) 的地址放进 64(SP),也就是 64(SP) 里面存放一个指针,指向 72(SP)
LEAQ    ""..autotmp_1+72(SP), AX
MOVQ    AX, ""..autotmp_3+64(SP)
  1. 把 48(SP) 的值也就是 $1 取出来放进 (SP)
MOVQ    "".a+48(SP), AX
MOVQ    AX, (SP)
  1. 调用 runtime.convT64,参数就是 (SP) 中的值,也就是 1
CALL    runtime.convT64(SB)

先看看 runtime.convT64 做了什么:

func convT64(val uint64) (x unsafe.Pointer) {
   if val < uint64(len(staticuint64s)) {
      x = unsafe.Pointer(&staticuint64s[val])
   } else {
      x = mallocgc(8, uint64Type, false)
      *(*uint64)(x) = val
   }
   return
}
  • 当 val 比较小的时候,会放进一个常量池,它是个常驻内存
  • 当 val 比较大的时候,会调用 mallocgc 来存放 可以看到,不管走哪个分支,val 都是放在堆上
  1. convT64 方法的返回值会自动放进 8(SP),然后又取出来放进 56(SP)。
MOVQ    8(SP), AX
MOVQ    AX, ""..autotmp_4+56(SP)
  1. 把 64(SP) 放到 CX 寄存器,我们前面已知 64(SP) 中存的是 72(SP) 的地址,所以 CX 中存的是 72(SP) 的指针。然后把 type.int(SB) 存进 72(SP)。
MOVQ    ""..autotmp_3+64(SP), CX
TESTB   AL, (CX)
LEAQ    type.int(SB), DX
MOVQ    DX, (CX)
  1. 从上下文中得知,AX 还是存放着 runtime.convT64 的返回值,是指向 1 的 unsafe.Pointer。把 AX 存进 8(CX),也就是 80(SP)。
MOVQ    AX, 8(CX)

至此,我们发现 72(SP) 和 80(SP) 存的分别是 type.int 和 unsafe.pointer,连起来刚好就是一个 eface!

  1. 把 64(SP) 放进 AX,就是把 72(SP) 的地址放进 AX
MOVQ    ""..autotmp_3+64(SP), AX
  1. 这里三个指令构造的是 interface 类型的切片,作为局部变量
MOVQ    AX, ""..autotmp_2+88(SP) 
MOVQ    $1, ""..autotmp_2+96(SP) 
MOVQ    $1, ""..autotmp_2+104(SP)
  1. 这里三个指令和上面的一模一样,都是构造 interface 类型的切片,但是这里构造的是形参,也就是用于 fmt.Println 的入参
MOVQ    AX, (SP)
MOVQ    $1, 8(SP)
MOVQ    $1, 16(SP)

至此,fmt.println 的逃逸流程分析完了。

整个逃逸流程总结如下:

  1. 把 1 放到栈上
  2. 调用 runtime.convT64(SB),把 1 拷贝了一份放到堆上,返回 1 在堆上的指针 unsafe.Pointer
  3. 在栈上构造 eface,eface 的 data 就是 1 的 unsafe.Pointer
  4. 构造 eface 类型的切片,调用 fmt.Println

所以 “a escapes to heap” 指的就是从栈上拷贝了一份放到堆上的 a。

参考: tonybai.com/2021/05/24/…