导航

Go Escape Analysis

发布时间:4 个月前 更新时间:4 months ago
开发

Golang 的逃逸分析机制确实非常出色,它能够自动分析函数内部变量的内存地址是否会返回到调用方(Caller)。基于此分析结果,编译器会智能地决定是将变量分配在栈上还是堆上。这样的设计极大地简化了内存管理,开发者可以方便地返回函数内部的变量而不必担心异常访问(例如 C/C++ 中的悬挂指针问题)。在调用方使用完毕后,Go 的垃圾回收(GC)机制会自动回收堆上不再被引用的变量,这大大减少了我们对内存的维护工作。

Escape Analysis 要解决的问题

从 C/C++ 深入学习计算机技术的IT从业者,都应该对堆(heap)和栈(Stack)有着’泾渭分明’的理解。在操作系统演化出现进程虚拟内存地址(virtual memory address)的概念后,如下图所示,应用程序的虚拟内存地址空间就被划分为堆内存区(如图中的heap)和栈内存区(如图中的stack):

alt text

图:一个进程的虚拟内存地址空间(图来自https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast)

在x86平台linux操作系统下,如上图,一般将栈内存区放在高地址,栈向下延伸;而堆内存去放在低地址,堆向上延伸,这样做的好处就是便于堆和栈可动态共享那段内存区域,在一般情况下无需担心堆栈碰撞的问题。

这是否意味着所有分配在堆内存区域的内存对象地址一定比分配在栈内存区域的内存对象地址要小呢?在C/C++中是这样的,但是在Go语言中,这是不一定的,因为go堆内存所使用的内存页(page)与goroutine的栈所使用的内存页是交织在一起的。

无论是栈内存还是堆内存,对于应用而言都是合法可用的内存地址空间。之所以将其区分开,是因为应用程序的内存分配和管理的需要。

栈内存上的对象的存储空间是自动分配和销毁的,无需开发人员或编程语言运行时过多参与,比如下面的这段C代码(用C代码更能体现栈内存与堆内存的差别):

#include <stdio.h>

void bar() {
    int e = 31;
    int f = 32;
    printf("e = %d\n", e);
    printf("f = %d\n", f);
}

void foo() {
    int c = 21;
    int d = 22;
    printf("c = %d\n", c);
    printf("d = %d\n", d);
}

int main() {
    int a = 11;
    int b = 12;
    printf("a = %d\n", a);
    printf("b = %d\n", b);
    foo();
    bar();
}

上面这段c程序算上main函数共有三个函数,每个函数中都有两个整型变量,C编译器自动为这些变量在栈内存上分配空间,我们无需考虑它什么时候被创建以及何时被销毁,我们只需在特定的作用域(其所在函数内部)使用它即可,而无需担心其内存地址不合法,因此这些被分配在栈内存上的变量也被称为“自动变量”。但是如果将其地址返回到函数的外部,那么函数外部的代码通过解引用而访问这些变量时便会出错,如下面示例:

在上文中,Tony Bai 老师讲的其实还不够细致。在函数外部去引用函数内变量时,在运行时是可以的。只是在静态编译时会被编译器阻止,所以这是一个编译时问题。如果你尝试让一个运行中的程序去访问一个已经被结束的函数的局部变量,那么是仍然可以访问,并且获取值的。这个需要使用到动态调试技术,去劫持程序运行的执行流。

#include <stdio.h>

int *foo() {
    int c = 11;
    return &c;
}

int main() {
    int *p = foo(); 
    printf("the return value of foo = %d\n", *p);
}

在下文中会有一个产生非法访问的错误,那个产生的原因并不是因为访问了栈上的空间,而是因为编译器进行了优化,判断为一个非法访问,所以强制赋值为0,以显示段访问错误。并不是说不能够进行访问,在这个场景下,直接修改会出现问题,于是我使用 -m32 选项重新编译,然后修改 eax 的值,出现如下结果。所以这里只是纠正一下内容,当我们的函数退出后,在没有操作系统的强制介入,删除物理页的情况下,在函数外仍然能够访问栈空间的值。只不过是在编译器的语义不允许该操作的存在,所以直接给我们进行了优化。

alt text

alt text

alt text

如代码所示,在上面这个例子中,我们将foo函数内的自动变量c的地址通过函数返回值返回给foo函数的调用者(main)了,这样当我们在main函数中引用该地址输出该变量值的时候,我们就会收到异常,比如在ubuntu上运行上述程序,我们会得到如下结果(在macos上运行,gcc会给出相同的警告,但程序运行不会dump core):

# gcc cstack_dumpcore.c
cstack_dumpcore.c: In function ‘foo’:
cstack_dumpcore.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr]
     return &c;
            ^~
# ./a.out
Segmentation fault (core dumped)

这样一来我们就需要一种内存对象,可以在全局(跨函数间)合法使用,这就是堆内存对象。但是和位于栈上的内存对象由程序自行创建销毁不同,堆内存对象需要通过专用API手工分配和释放,在C中对应的分配和释放方法就是malloc和free:

// github.com/bigwhite/experiments/blob/master/go-escape-analysis/c/cheap.c

#include <stdio.h>
#include <stdlib.h>

int *foo() {
    int *c = malloc(sizeof(int));
    *c = 12;
    return c;
}

int main() {
    int *p = foo();
    printf("the return value of foo = %d\n", *p);
    free(p);
}

在这个示例中我们使用malloc在foo函数中分配了一个堆内存对象,并将该对象返回给main函数,main函数使用完该对象后调用了free函数手工释放了该堆内存块。

显然和自动变量相比,堆内存对象的生命周期管理将会给开发人员带来很大的心智负担。为了降低这方面的心智负担,带有GC(垃圾回收, Garbage Collection)的编程语言出现了,比如Java、Go等。这些带有GC的编程语言会对位于堆上的对象进行自动管理。当某个对象不可达时(即没有其对象引用它时),它将会被回收并被重用。

但GC的出现虽然降低了开发人员在内存管理方面的心智负担,但GC不是免费的,它给程序带来的性能损耗是不可忽视的,尤其是当堆内存上有大量待扫描的堆内存对象时,将会给GC带来过大的压力,从而使得GC占用更多本应用于处理业务逻辑的计算和存储资源。于是人们开始想方法尽量减少在堆上的内存分配,可以在栈上分配的变量尽量留在栈上。

逃逸分析(escape analysis)就是在程序编译阶段根据程序代码中的数据流,对代码中哪些变量需要在栈上分配,哪些变量需要在堆上分配进行静态分析的方法。一个理想的逃逸分析算法自然是能将那些人们认为需要分配在栈上的变量尽可能保留在栈上,尽可能少的“逃逸”到堆上的算法。但这太过理想,各种语言都有自己的特殊情况,各种语言的逃逸算法的精确度实际都会受到这方面的影响。

Go 语言的逃逸分析

Go从诞生那天起,逃逸分析就始终伴随其左右。正如上面说到的逃逸分析的目标,Go编译器使用逃逸分析来决定哪些变量应该在goroutine的栈上分配,哪些变量应该在堆上分配。

截至目前,Go一共有两个版本的逃逸分析实现,分水岭在Go 1.13版本。Go 1.13版本之前是Go逃逸分析的第一版实现,位于Go源码的src/cmd/compile/internal/gc/esc.go中(以go 1.12.7版本为例),代码规模2400多行;Go 1.13版本中加入了由Matthew Dempsky重写的第二版逃逸分析,并默认开启,可以通过-gcflags="-m -newescape=false"恢复到使用第一版逃逸分析。之所以重写,主要是考虑第一版代码的可读性和可维护性问题,新版代码主要位于Go项目源码的src/cmd/compile/internal/gc/escape.go中,它将逃逸分析代码从上一版的2400多行缩减为1600多行,并作了更为完整文档和注释。但注意的是新版代码在算法精确性上并没有质的变化。

但即便如此,经过了这么多年的“修修补补”,Dmitry Vyukov 2015年提出的那些“Go Escape Analysis Flaws”多数已经fix了。Go项目中内置了对逃逸分析的详尽的测试代码(位于Go项目下的test/escape*.go文件中)。

在新版逃逸分析实现的注释中($GOROOT/src/cmd/compile/internal/gc/escape.go),我们可以大致了解逃逸分析的实现原理。注释中的原理说明中提到了算法基于的两个不变性:

  1. 指向栈对象的指针不能存储在堆中(pointers to stack objects cannot be stored in the heap):意思就是堆中不能存储指向栈数据的指针。
  2. 指向栈对象的指针不能超过该栈对象的存活期(即指针不能在栈对象被销毁后依旧存活)(pointers to a stack object cannot outlive that object):即如果在编写代码时在 Caller 处返回了一个栈内数据的指针,那么该栈内数据不会被编译器存储在栈中,而是通过逃逸分析,逃逸到堆上面。

源码注释中也给出Go逃逸分析的大致原理和过程。Go逃逸分析的输入是Go编译器解析了Go源文件后所获得的整个程序的抽象语法树(Abstract syntax tree,AST):

// $GOROOT/src/cmd/compile/internal/gc/go.go
var xtop []*Node

Main函数中,xtop被传入逃逸分析的入口函数escapes

// $GOROOT/src/cmd/compile/internal/gc/main.go

// Main parses flags and Go source files specified in the command-line
// arguments, type-checks the parsed Go package, compiles functions to machine
// code, and finally writes the compiled package definition to disk.
func Main(archInit func(*Arch)) {
    ... ...
    // Phase 6: Escape analysis.
    // Required for moving heap allocations onto stack,
    // which in turn is required by the closure implementation,
    // which stores the addresses of stack variables into the closure.
    // If the closure does not escape, it needs to be on the stack
    // or else the stack copier will not update it.
    // Large values are also moved off stack in escape analysis;
    // because large values may contain pointers, it must happen early.
    timings.Start("fe", "escapes")
    escapes(xtop)
    ... ...
}

下面是escapes函数的实现:

// $GOROOT/src/cmd/compile/internal/gc/esc.go
func escapes(all []*Node) {
    visitBottomUp(all, escapeFuncs)
}

// $GOROOT/src/cmd/compile/internal/gc/scc.go
// 强连接node - 一个数据结构
func visitBottomUp(list []*Node, analyze func(list []*Node, recursive bool)) {
    var v bottomUpVisitor
    v.analyze = analyze
    v.nodeID = make(map[*Node]uint32)
    for _, n := range list {
        if n.Op == ODCLFUNC && !n.Func.IsHiddenClosure() {
            v.visit(n)
        }
    }
}

// $GOROOT/src/cmd/compile/internal/gc/escape.go

// escapeFuncs performs escape analysis on a minimal batch of
// functions.
func escapeFuncs(fns []*Node, recursive bool) {
    for _, fn := range fns {
        if fn.Op != ODCLFUNC {
            Fatalf("unexpected node: %v", fn)
        }
    }

    var e Escape
    e.heapLoc.escapes = true

    // Construct data-flow graph from syntax trees.
    for _, fn := range fns {
        e.initFunc(fn)
    }
    for _, fn := range fns {
        e.walkFunc(fn)
    }
    e.curfn = nil

    e.walkAll()
    e.finish(fns)
}

根据注释,escapes的大致原理是(直译):

  • 首先,构建一个有向加权图,其中顶点(称为”location”,由gc.EscLocation表示)代表由语句和表达式分配的变量,而边(gc.EscEdge)代表变量之间的赋值(权重代表寻址/取地址次数)。
  • 接下来,遍历(visitBottomUp)该有向加权图,在图中寻找可能违反上述两个不变量条件的赋值路径。 违反上述不变量的赋值路径。如果一个变量v的地址是储存在堆或其他可能会超过它的存活期的地方,那么v就会被标记为需要在堆上分配。
  • 为了支持函数间的分析,算法还记录了从每个函数的参数到堆的数据流以及到其结果的数据流。算法将这些信息称为“参数标签(parameter tag)”。这些标签信息在静态调用时使用,以改善对函数参数的逃逸分析。

当然即便看到这,你可能依旧一头雾水,没关系,这里不是讲解逃逸分析原理,如果想了解原理,那就请认真阅读那2400多行代码。

注:有一点需要明确,那就是静态逃逸分析也无法确定的对象会被放置在堆上,后续精确的GC会处理这些对象,这样最大程度保证了代码的安全性。

到这里,我想你应该明白了逃逸分析到底是怎么一回事

后面的逃逸分析案例,等以后有空了再写吧