变量逃逸

在Go语言里面,变量逃逸这个概念让很多人感到疑惑。直白来讲,就是原本应该分配在函数栈帧上的局部变量,因为其生命周期超出了所在函数的生命周期,所以编译器将其由栈分配改为堆分配,也就是我们通常所讲的“变量逃逸到了堆上”。

栈帧上的局部变量,在函数返回时与栈帧一同销毁,而堆上变量则是等到不再使用时,由垃圾回收机制负责回收的。所以变量逃逸到堆上后,就不会受到当前函数返回的影响。

和变量逃逸密切相关的,自然是变量地址的传递,比如返回局部变量的地址,也可能是将局部变量的地址赋给包级别的指针变量。以下示例代码给出了最常见的变量逃逸场景,分别使用了new和&:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Variable struct {
Name string
}

var global *Variable

func useNew(name string) *Variable {
v := new(Variable)
v.Name = name
return v
}

func useAddr(name string) {
v := Variable{
Name: name,
}
global = &v
}

根据Go官方的说法,使用了new和&并不一定会造成变量堆分配,编译器会进行逃逸分析,只有真正逃逸的变量才会被分配到堆上,否则还是分配在栈上。但是这种说法又在一定程度上增加了复杂性,使得变量逃逸这个概念更加难以描述清楚。既然如此,不妨换一个角度来理解。

🎯倒转经脉

在金庸老师的笔下,欧阳锋倒转经脉练习九阴真经,结果威力无穷,东邪、南帝和北丐联手都难以抵挡。当然也带来了很大的危害,欧阳锋因此走火入魔,连自己是谁都不知道。

这里做个比喻,只是想要说明,换个角度来看问题,或者干脆从相反的方向来理解,可能要容易得多。就拿变量逃逸来讲,从相反的方向来描述:

  1. 首先,凡是使用了new或&来获取到地址的“局部”变量,认为都应该从堆上分配;
  2. 然后,编译器会对以上变量进行优化,将生命周期仅限于当前函数内的变量改为栈上分配。

这样就将高大上的“变量逃逸”变成了普通的“编译器优化”,似乎容易理解多了。相比之下,原来的“变量逃逸”概念似乎才属于“倒转经脉”,威力大到难以理解。

🎧反编译

想要确认变量是否在堆上分配,最直接的办法莫过于查看汇编代码了。Go自带了反编译工具,可以直接在命令行反编译指定的函数。假如上面的示例代码位于package main中,那么可以通过如下命令反编译useNew函数:

1
$ go tool objdump -S -s '^main.useNew$' filename

涉及到堆分配的变量,一般会通过runtime.newobject来分配,该函数就是go自带的new函数的具体实现。如下为具体代码,摘抄自Go源码:

1
2
3
4
5
6
// implementation of new builtin
// compiler (both frontend and SSA backend) knows the signature
// of this function
func newobject(typ *_type) unsafe.Pointer {
return mallocgc(typ.size, typ, true)
}

实际上内部就是直接调用runtime.mallocgc来分配一个对象,没有任何额外的处理。在有变量逃逸的地方,一般可以发现类似如下的汇编代码:

1
2
3
4
0x4c1afa              488d0dbf0b0200          LEAQ type.*+132800(SB), CX
0x4c1b01 48890c24 MOVQ CX, 0(SP)
0x4c1b05 e806a9f4ff CALL runtime.newobject(SB)
0x4c1b0a 488b442408 MOVQ 0x8(SP), AX

🎮何时逃逸

前面提到了两种典型的变量逃逸场景:返回函数局部变量地址,以及将局部变量地址赋值给包级别的指针变量。如果进一步提炼,可以泛化为“函数局部变量地址的生命周期超出函数的生命周期时”,会发生变量逃逸。

1
2
3
4
func newInt(p **int) {
var n int
*p = &n
}

以上代码中p指向一个int指针,函数把局部变量n的地址赋给了p所指向的指针,这个指针可能是caller的一个局部变量,也可能是包级别变量,也可能位于堆上,总之n的地址的生命周期超过了当前函数,n分配在堆上。

1
2
3
4
5
6
7
func mc(i int) func() int {
f := func() int {
return i
}
i++ // 关键所在,无此行不逃逸
return f
}

以上代码展示了因“闭包捕获”造成变量逃逸的情况,因为闭包捕获变量i,造成变量i的生命周期超出函数mc。假如从i被赋初值以后,函数mc和闭包函数都未对i进行修改的话,闭包函数就会捕获i的值而不是地址,此处应该是编译器的优化处理,以减少堆分配。若是任何一个函数修改了i,也就使其逃逸到堆上,闭包函数也会捕获其地址。

所以上面关于变量何时逃逸的论述中说“局部变量地址的生命周期超出函数的生命周期时”,有意强调“局部变量地址”而不仅仅是“局部变量”,就是针对编译器对闭包捕获列表的优化。

或许你会说,以上代码完全等价于:

1
2
3
4
5
6
7
func mc(i int) func() int {
i++
f := func() int {
return i
}
return f
}

此时是不是可以直接捕获i自增后的值呢?我在go1.14上测试确实会捕获值而不是地址,i没有逃逸。那编译器为什么没有帮我们进行这样的优化呢?

编译器能够进行的优化比较有限,稳妥起见不会改变代码的语义,你不能期望它去帮你优化代码逻辑,甚至来个重构。对吧?