什么是逃逸
垃圾回收是Go的一个很方便的特性–其自动的内存管理使代码更整洁,同时减少内存泄漏的可能性。但是,由于垃圾回收需要周期性的停止程序从而去收集不用的对象,不可避免的会增加额外开销。Go编译器是智能的,它会自动决定一个变量是应该分配在堆上从而在将来便于回收,还是直接分配到函数的栈空间。对于分配到栈上的变量,其与分配到堆上的变量不同之处在于:随着函数的返回,栈空间会被销毁,从而栈上的变量被直接销毁,不需要额外的垃圾回收开销。
但是在有些情况下,本应该分配到栈上的变量,被编译器智能的分配到了堆上,变量从栈逃逸到堆上就是逃逸现象。
逃逸分析
package main
import "fmt"
type People struct{}
func main() {
f1()
f2()
}
func f1() {
a := &People{}
b := &People{}
fmt.Println(a == b)
}
func f2() {
c := &People{}
d := &People{}
fmt.Printf("%p\n", c)
fmt.Printf("%p\n", d)
fmt.Println(c == d)
}
正常执行
> go run teststruct.go
false
0x5a6da8
0x5a6da8
true
逃逸分析
> go run -gcflags="-m -l" teststruct.go
# command-line-arguments
.\teststruct.go:13:7: &People literal does not escape
.\teststruct.go:14:7: &People literal does not escape
.\teststruct.go:15:13: ... argument does not escape
.\teststruct.go:15:16: a == b escapes to heap
.\teststruct.go:19:7: &People literal escapes to heap
.\teststruct.go:20:7: &People literal escapes to heap
.\teststruct.go:21:12: ... argument does not escape
.\teststruct.go:22:12: ... argument does not escape
.\teststruct.go:23:13: ... argument does not escape
.\teststruct.go:23:16: c == d escapes to heap
0x5a6da8
0x5a6da8
true
escapes to heap
就说明发生了逃逸现象。通过分析可得知变量 a, b 均是分配在栈中,而变量 c, d 分配在堆中。关键原因是 fmt 中的打印函数很多用到了反射,会造成逃逸的行为。
具体分析问题
现在再来看上面的例子,是不是觉得很奇怪,即使发生了逃逸,a == b
难道不应该是false
吗?
从打印信息看,变量a和b存储的值都是0x5a6da8
,也就是,他们指向的是同一个地址。
这里主要与 Go runtime 的一个优化细节有关,如下:
// runtime/malloc.go
var zerobase uintptr
变量 zerobase 是所有 0 字节分配的基础地址。更进一步来讲,就是空(0字节)的在进行了逃逸分析后,往堆分配的都会指向 zerobase 这一个地址。
所以空 struct 在逃逸后本质上指向了 zerobase,其两者比较就是相等的。
为什么没逃逸不相等?
从 Go spec 来看,这是 Go 团队刻意而为之的设计,不希望大家依赖这一个来做判断依据。如下:
This is an intentional language choice to give implementations flexibility in how they handle pointers to zero-sized objects. If every pointer to a zero-sized object were required to be different, then each allocation of a zero-sized object would have to allocate at least one byte. If every pointer to a zero-sized object were required to be the same, it would be different to handle taking the address of a zero-sized field within a larger struct.
还说了一句很经典的,细品:
Pointers to distinct zero-size variables may or may not be equal.
另外空 struct 在实际使用中的场景是比较少的,常见的是:
- 设置 context,传递时作为 key 时用到。
- 设置空 struct 业务场景中临时用到。
但业务场景的情况下,也大多数会随着业务发展而不断改变,假设有个远古时代的 Go 代码,依赖了空 struct 的直接判断,岂不是事故上身?
不可直接依赖
因此 Go 团队这番操作,与 Go map 的随机性如出一辙,避免大家对这类逻辑的直接依赖,是值得思考的。
而在没逃逸的场景下,两个空 struct 的比较动作,你以为是真的在比较。实际上已经在代码优化阶段被直接优化掉,转为了 false。
因此,虽然在代码上看上去是 == 在做比较,实际上结果是 a == b 时就直接转为了 false,比都不需要比了。
你说妙不?
没逃逸让他相等
既然我们知道了他是在代码优化阶段被优化的,那么相对的,知道了原理的我们也可以借助在 go 编译运行时的 gcflags 指令,让他不优化。
在运行前面的例子时,执行 -gcflags="-N -l"
指令:
go run -gcflags="-N -l" main.go
0xc000092f06 0xc000092f06 true
&{} &{}
0x118c370 0x118c370 true
你看,两个比较的结果都是 true 了。
总结
在今天这篇文章中,我们针对 Go 语言中的空结构体(struct)的比较场景进行了进一步的补全。经过这两篇文章的洗礼,你会更好的理解 Go 结构体为什么叫既可比较又不可比较了。
而空结构比较的奇妙,主要原因如下:
若逃逸到堆上,空结构体则默认分配的是 runtime.zerobase
变量,是专门用于分配到堆上的 0 字节基础地址。因此两个空结构体,都是 runtime.zerobase
,一比较当然就是 true 了。
若没有发生逃逸,也就分配到栈上。在 Go 编译器的代码优化阶段,会对其进行优化,直接返回 false。并不是传统意义上的,真的去比较了。
不会有人拿来出面试题,不会吧,为什么 Go 结构体说可比较又不可比较?