go

Golang GC系列之一 – Golang逃逸分析

TL;DR

一言以蔽之,逃逸分析是一种“通过检查变量的作用域是否超出了它所在的栈来决定是否将它分配在堆上”的技术,其中“变量的作用域超出了它所在的栈”这种行为即被称为逃逸。逃逸分析在大多数语言里属于静态分析:在编译期由静态代码分析来决定一个值是否能被分配在栈帧上,还是需要“逃逸”到堆上。Go也一样,但Go中没有显式要求编译器如何分配变量的关键字[1],Go会在编译时自行妥善处理。

正文

概述

在C++一类的没有运行时的语言中,将一个栈上分配的对象以指针的形式传回到上一帧使用是一种UB(undefined behavior),见过这个缩写的同学想必都知道它在C++中的分量。由于栈上每一帧的空间与这一帧的生存期相同,离开这一帧再使用在这个帧的空间上分配的对象时,没人能保证会发生什么错误。

然而在Go中,这个故事的结果是不同的,Go在编译时会分析代码,将所有离开了自己所在的帧的变量直接分配到堆上,从而避免了访问已被释放的内存的问题,同时将不会“逃逸”的变量分配在栈上,从而高效的释放空间。这就是逃逸分析,也是这篇文章要详细讲述的内容。

连续栈

连续栈的实现与本文并没有太大关系,本节作为正文内容的铺垫,只做简要说明。连续栈是Goroutine使用的动态伸缩的栈,一开始每个Goroutine只会被分配到一个很小的栈空间(2kb),当某个Goroutine栈空间不足时,就会开辟一份更大的空间,把栈“移动”到新的空间上。这样可以避免Goroutine产生过多非必要的内存开销。初始栈空间大小的定义可以在golang/go项目下的src/runtime/stack.go中找到,从Go 1.4到目前为止的1.13中,Goroutine初始栈空间设定维持在2kb[2]

// The minimum size of stack used by Go code 
_StackMin = 2048

连续栈这个名字其实有些避重就轻,需要结合它的来历才能理解它的用意(简单说就是Go 1.2之前的动态栈不是连续的,产生了一些问题,在1.3上被设计成连续的了)。对于Go整体而言,这项技术的关键点并不在于栈的连续,而是在于栈空间的动态伸缩

栈内存

在Go中不允许出现一个Goroutine栈上的指针指向另一个Goroutine的栈空间,也就是禁止栈与栈访问彼此的内存空间。这是由于Golang的连续栈机制决定了栈上的内存会随着栈空间增长或收缩而被重新分配,栈空间会被指针引用意味着Go runtime将需要追踪这些指针,这一来很复杂,二来更新这些指针将引入很大的STW(stop the word)延迟。

这里有一个栈空间由于动态增长而发生地址变更的例子。观察输出中的第2、6行,可以看到main这一帧中string的地址被改变了两次

堆内存

相比栈内存,堆内存不是自释放的,需要依赖GC来释放,同时栈上的对象可以通过偏移量进行直接寻址,而堆上的对象需要通过指针间接访问,所以堆内存的访问代价也要高于栈内存。

垃圾回收(GC)产生的影响相对间接寻址通常更大,有堆内存就以为着垃圾回收将会介入并清理无用的堆内存。GC运行会占用你可用CPU总量的25%,同时产生一个亚毫秒级的“stop the world”延迟。好处在于有了GC就不需要担心堆内存管理了。我以为这是值得的,堆内存管理在C/C++中一直都是Bug重灾区。

逃逸分析

逃逸分析是一种用来确定指针的作用域的静态分析技术。进行逃逸分析的目的通常是优化。根据维基百科[3],逃逸分析能够:

  • 将堆上的分配行为改为在栈上分配,也可以反过来
  • 同步优化:如果某个对象只能被一个线程访问到,那访问这个对象不需要锁一类的线程同步机制

从一个Go中栈的变量声明被分配在堆内存上的例子开始,下面的变量声明既可能被分配在栈上也可能被分配在堆上。

cat := cat {
    name : "狗子",
    kind : "shorthair",
    weight : 4200,
    colors : []string{"white","silver"},
}

最终这个变量分配在哪里取决于编译器执行逃逸分析的结果:

如果有指针指向这个cat对象并且这个指针最终“逃逸”出了这个函数的范围,则它将被分配在堆上。这里的“逃逸”指在这个函数以外的地方可以访问到它,比如被闭包函数引用,又比如将指针作为返回值返回到上一帧。

比如下面这个例子:

func grabACat() *cat {
    cat := cat{
        name:   "狗子",
        kind:   "shorthair",
        weight: 4200,
        colors: []string{"white", "silver"},
    }

    return &cat
}

这个例子中的cat看起来就像一个栈上的对象,但经过逃逸分析,它最终会被分配在堆上。因此在Go中不需要担心一个指针指向的是一块已经失效的内存,因为Go编译器会在编译时帮我们判断这种情况并将存在这种问题的对象直接分配在堆上,这一切都是不需要被开发者感知的。

参考文档

  1. Language Mechanics On Escape Analysis
  2. Go: How Does the Goroutine Stack Size Evolve?
  3. Escape analysis – Wikipedia
分类: go
文章已创建 23

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

相关文章

开始在上面输入您的搜索词,然后按回车进行搜索。按ESC取消。

返回顶部