震惊:结合 Beast Mode 的逃逸分析浅解

542 阅读4分钟

本文已参加「新人创作礼」活动,一起开启掘金创作之路。

在 「高性能 Go 语言发行版优化与落地实践」第三届字节跳动青训营 - 后端专场 这一课中,我们了解到了Beast mode —— 编译器优化方面推出的产品。

Beast mode 中包含有非常多的优化方法,例如针对 函数内联逃逸分析

我们可以来简单聊一聊 Golang 中的逃逸分析。

函数内联(Inlining)

将被调用函数的函数体(callee)的副本替换到调用位置(caller)上,同时重写代码以反映参数的绑定。

它可以实现:

  • 消除调用开销,如参数传递、保存寄存器等
  • 将过程间分析的问题转换为过程内分析,帮助其他优化,如逃逸分析
  • 通过调用和被调用函数的规模决定是否内联,且在大多数情况下是正向优化,性能提升

但它也存在有不少缺点:

  • 受限严重:

    • 语言特性:例如 interface, defer 等等,限制了函数内联
    • 内联策略非常保守
  • 函数体变大,instruction cache(icache)不友好

  • 编译生成的 Go 镜像文件变大

而 Beast Mode 能够调整函数内联的策略,使更多函数被内联,带来了很多的影响:

  • 函数调用开销降低,让更多函数被内联

  • 增加其他优化的机会:逃逸分析

  • 开销增加

    • Go 镜像大小略有增加~10%
    • 编译时间增加

逃逸分析

定义:分析代码中指针的动态作用域,即指针在何处可以被访问

大致思路:

  • 从对象分配处出发,沿着控制流,观察数据流。若发现指针 p 在当前作用域 s:

    • 作为参数传递给其他函数;
    • 传递给全局变量;
    • 传递给其他的 goroutine;
    • 传递给已逃逸的指针指向的对象;
  • 则指针 p 逃逸出 s,反之则没有逃逸出 s.

了解了什么是逃逸分析后,我们可以通过一个非常常见的例子来说明这一点:

作为一个常年使用 C++ 的选手,我们知道:由于栈的特性,外部函数无法使用子函数的局部变量。而在 Golang 中,对于这一点却产生了不同的效果,如下所示:

package main

func foo(arg_val int) *int {

	var ans int = 6
	return &ans
}

func main() {

	test := foo(666)

	println(*test)
}

输出:6

按理 C++ 的思想来说,子函数的ans应当无法运行,但在 Golang 中却没有任何问题,这是为什么?

因为ans变量被分配在了堆上。

我们知道,应用程序在运行时只会存在一个堆,但可以存在多个栈。

Golang 会通过 逃逸分析 自动决定把一个变量放在栈或是堆:当发现变量的作用域没有跑出函数范围,就有极大可能将变量分配在栈上;若变量的作用域跑出函数范围,则一定会分配在堆上。除此之外,如果局部变量非常大,也会将其分配在堆上。

接下来我们通过 详解 Go 逃逸分析真的超级详细!!!),对以下四种发生逃逸现象的具体实例展示详见:

  • 变量类型无法确定
  • 暴露给外部指针
  • 变量所占内存较大
  • 变量大小不确定

在这里 Beast Mode 同样也进行了优化:

  • 函数内联拓展函数边界,更多对象不逃逸
  • 未逃逸出当前函数的指针指向的对象可以在栈上分配,这使得:
    • 对象在栈上分配和回收很快:移动 sp 即可分配和回收
    • 减少在堆上分配对象,降低 GC 负担

综上所述:

逃逸分析是一个非常强大的功能,当然在日常编写程序时也需要注意,我们依旧要注重对栈和堆变量的作用域的控制(如同 C++ 一样),知道变量的存储位置对程序的效率有着一定的帮助,因为分配在栈上的变量越多,堆上的变量越少,就越可以减轻内存分配的开销,减小 gc 的压力,提高程序的运行速度。

同样的,如果对程序的效率不做要求的话,那么就完全不必得知所定义的变量到底是分配在栈上还是堆上的,因为 Golang 中的变量只要被引用就会一直存活,存储在栈上还是堆上由 Golang 内部实现,与具体的语法无关。