1.9 逃逸分析
逃逸分析是Go语言中重要的优化阶段,用于标识变量内存应该被分配在栈区还是堆区。在传统的C或C++语言中,开发者经常会犯的错误是函数返回了一个栈上的对象指针,在函数执行完成,栈被销毁后,继续访问被销毁栈上的对象指针,导致出现问题。Go语言能够通过编译时的逃逸分析识别这种问题,自动将该变量放置到堆区,并借助Go运行时的垃圾回收机制(详见第19~20章)自动释放内存。编译器会尽可能地将变量放置到栈中,因为栈中的对象随着函数调用结束会被自动销毁,减轻运行时分配和垃圾回收的负担。
在Go语言中,开发者模糊了栈区与堆区的差别,不管是字符串、数组字面量,还是通过new、make标识符创建的对象,都既可能被分配到栈中,也可能被分配到堆中。分配时,遵循以下两个原则:
◎ 原则1:指向栈上对象的指针不能被存储到堆中
◎ 原则2:指向栈上对象的指针不能超过该栈对象的生命周期
Go语言通过对抽象语法树的静态数据流分析(static data-flow analysis)来实现逃逸分析,这种方式构建了带权重的有向图。
简单的逃逸现象举例如下:
在上例中,变量z为全局变量,是一个指针。在函数中,变量z引用了变量a的地址。如果变量a被分配到栈中,那么最终程序将违背原则2,即变量z超过了变量a的生命周期,因此变量a最终将被分配到堆中。可以通过在编译时加入-m=2标志打印出编译时的逃逸分析信息。如下所示,表明变量a将被放置到堆中。
Go语言在编译时构建了带权重的有向图,其中权重可以表明当前变量引用与解引用的数量。下例为p引用q时的权重,当权重大于0时,代表存在*解引用操作。当权重为-1时,代表存在&引用操作。
并不是权重为-1就一定要逃逸,例如在下例中,虽然z引用了变量a的地址,但是由于变量z并没有超过变量a的声明周期,因此变量a与变量z都不需要逃逸。
为了理解编译器带权重的有向图,再来看一个更加复杂的例子。在该案例中有多次的引用与解引用过程。
最终编译器在逃逸分析中的数据流分析,会被解析成如图1-7所示的带权重的有向图。其中,节点代表变量,边代表变量之间的赋值,箭头代表赋值的方向,边上的数字代表当前赋值的引用或解引用的个数。节点的权重=前一个节点的权重+箭头上的数字,例如节点m的权重为2-1=1,而节点l的权重为1-1=0。
图1-7 逃逸分析带权重的有向图
遍历和计算有向权重图的目的是找到权重为-1的节点,例如图1-7中的new(int)节点,它的节点变量地址会被传递到根节点o中,这时还需要考虑逃逸分析的分配原则,o节点为全局变量,不能被分配在栈中,因此,new(int)节点创建的变量会被分配到堆中。
实际的情况更加复杂,因为一个节点可能拥有多条边(例如结构体),而节点之间可能出现环。Go语言采用Bellman Ford算法遍历查找有向图中权重小于0的节点,核心逻辑位于gc/escape.go中。
另一个有趣的话题是defer这一关键特性在不同情况下的逃逸分析,将在第10章详细介绍。