前言
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
和 eface
。iface
和 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 放进 SP 寄存器往上数 48 个字节的位置,对应着第六行的操作
a:=1
。
MOVQ $1, "".a+48(SP)
- 把 72(SP) 的地址放进 64(SP),也就是 64(SP) 里面存放一个指针,指向 72(SP)
LEAQ ""..autotmp_1+72(SP), AX
MOVQ AX, ""..autotmp_3+64(SP)
- 把 48(SP) 的值也就是 $1 取出来放进 (SP)
MOVQ "".a+48(SP), AX
MOVQ AX, (SP)
- 调用 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 都是放在堆上。
- convT64 方法的返回值会自动放进 8(SP),然后又取出来放进 56(SP)。
MOVQ 8(SP), AX
MOVQ AX, ""..autotmp_4+56(SP)
- 把 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)
- 从上下文中得知,AX 还是存放着 runtime.convT64 的返回值,是指向 1 的
unsafe.Pointer
。把 AX 存进 8(CX),也就是 80(SP)。
MOVQ AX, 8(CX)
至此,我们发现 72(SP) 和 80(SP) 存的分别是 type.int 和 unsafe.pointer,连起来刚好就是一个 eface!
- 把 64(SP) 放进 AX,就是把 72(SP) 的地址放进 AX
MOVQ ""..autotmp_3+64(SP), AX
- 这里三个指令构造的是 interface 类型的切片,作为局部变量
MOVQ AX, ""..autotmp_2+88(SP)
MOVQ $1, ""..autotmp_2+96(SP)
MOVQ $1, ""..autotmp_2+104(SP)
- 这里三个指令和上面的一模一样,都是构造 interface 类型的切片,但是这里构造的是形参,也就是用于 fmt.Println 的入参
MOVQ AX, (SP)
MOVQ $1, 8(SP)
MOVQ $1, 16(SP)
至此,fmt.println 的逃逸流程分析完了。
整个逃逸流程总结如下:
- 把 1 放到栈上
- 调用 runtime.convT64(SB),把 1 拷贝了一份放到堆上,返回 1 在堆上的指针 unsafe.Pointer
- 在栈上构造 eface,eface 的 data 就是 1 的 unsafe.Pointer
- 构造 eface 类型的切片,调用 fmt.Println
所以 “a escapes to heap” 指的就是从栈上拷贝了一份放到堆上的 a。