高质量 Go 语言发行版优化与落地实践 | 青训营笔记

83 阅读7分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天

从各个层面来看性能优化:

  1. 业务层优化

    1. 针对特定业务场景,具体问题具体分析
    2. 容易获得较大性能提升
  2. 语言运行时优化

    1. 解决更通用的性能问题
    2. 考虑更多场景
    3. Tradoffs
  3. 数据驱动

    1. pprof
    2. 依靠数据而不是猜测
    3. 首先优化最大瓶颈

本次课程讲解了内存管理策略,主要是 GC 方面的管理及其优化。这些算法的思想并不复杂,通常是一些通过设置cache、预分配等简单策略来实现的优化。在 Go 编译器优化方面,函数内联通过减小复杂的过程间分析,使得程序运行效率得以提升,通过逃逸分析,使得未逃逸变量分配到栈上来使得运行效率得以提升。

自动内存管理

自动内存管理是指程序语言运行时系统管理动态内存。避免了手动内存管理,专注于实现业务逻辑。它需要保持内存使用的正确性和安全性:double-free problem, use-after-free problem。

它专注于三个任务:

  1. 为新对象分配空间
  2. 找到存活对象
  3. 回收死亡对象的内存空间

相关概念:

  1. Mutator:业务线程,分配新对象,修改对象指向关系。

  2. Collector:GC 线程,找到存活对象,回收死亡对象的内存空间。

  3. Serial GC:只有一个 collector。

  4. Parallel GC:支持多个 collectors 同时回收的 GC 算法。

  5. Concurrent GC:mutator 和 collectors 可以同时执行。

    1. Collector 必须感知对象指向关系的改变。
  6. 如何评价 GC 算法:

    • 安全性(Safety):不能回收存活的对象「基本要求」
    • 吞吐率(Throughput):1 - GC时间/程序执行总时间「花在 GC 上的时间」
    • 暂停时间(Pause time):Stop the world (STW)「业务是否感知」
    • 内存开销(Space overhead)「GC 元数据开销」
  7. 追踪垃圾回收(Tracing garbage collection)

  8. 引用计数(Reference counting)

追踪垃圾回收过程

条件:指针指向关系不可达的对象可被回收。

变量引用关系是一张有向图,出发点是「静态变量」,「全局变量」,「常量」,「线程栈」等。从它们出发,找到所有的可达对象。清理时,有如下几种方式:

  1. 将存活对象复制到另外的内存空间(Copying GC)
  2. 将死亡对象的内存标记为「可分配」(Mark-sweep GC),即使用 free-list 管理空闲内存
  3. 移动并整理存活对象(Mark-compact GC),即原地整理

对于不同生命周期的对象,使用不同的标记和清理策略。

分代 GC

分代 GC 即 Generational GC,基于的假设是大多数对象在分配出来后很快就不再使用了。定义每个对象的年龄是经历过的 GC 次数。这样做的目的是对不同生命周期的对象,制定不同的 GC 策略,降低整体管理开销。

不同年龄的对象处于 heap 的不同区域

  1. 年轻代,使用常规的对象分配,由于存活对象少,所以使用 Copying GC。这样做 GC 吞吐率高。
  2. 老年代,对象一直存在,反复复制开销较大,所以使用 Mark-sweep GC。

引用计数

类似一个有向图,依赖关系即图中的连边,对象存活的条件是其引用数大于 0。

这样做的优点在于内存管理的操作被平摊到程序执行过程中,内存管理不需要了解 runtime 的实现细节。

而缺点也有很多:

  1. 维护引用计数的开销较大:通过原子操作保证对引用技术操作的原子性和可见性
  2. 无法回收环形数据结构 —— weak reference
  3. 回收内存时依然可能引发暂停

Go 内存管理及优化

分块

提前将内存分块,称作 mspan,这个大块依然可以被划分:

  • noscan mspan:分配不包含指针的对象,GC 时不需要扫描
  • scan mspan:分配包含指针的对象,GC 需要扫描

有对象需要分配时,根据对象的大小,选择最合适的块返回。

缓存

mcache 管理着一组 mspan,如果 mcache 里面没有 mspan 时,会向 mcentral 申请未分配的 mspan。而 mspan 回收时,不会立刻归还给 OS,而会缓存在 mcentral 中。

优化方案 Balanced GC

对象分配是非常高频的操作:每秒分配 GB 级别的内存。Go 内存分配比较耗时,其分配路径也很长:g->m->p->mcache->mspan->memory block->return pointer

字节提供的优化方案是对于每一个 g 都绑定一大块内存(1KB),称作 goroutine allocation buffer (GAB),在 GAB 中,用于 noscan 类型的小对象分配小于 128 B,同时使用三个指针维护它:base, end, top。

这样的方法风格使得对象分配时无须和其他分配请求互斥。

GAB 的本质是将多个小对象的分配合并成一次大对象的分配。但这样做也会导致内存释放有一定延迟。

而对于延迟释放问题,可以当 GAB 总大小超过一定阈值时,将 GAB 中存活的对象复制到另外分配的 GAB 中,原先的 GAB 可以释放,避免内存泄漏。这样做的本质就是用 Copying GC 的算法管理小对象。

编译器和静态分析

编译器

编译器是重要的系统软件,它可以识别符合语法和非法,从而生成正确且高效的代码。

大致流程如下:

分析部分:

  1. 词法分析,生成词素
  2. 语法分析,生成语法树
  3. 语义分析,收集类型信息,进行语义检查
  4. 中间代码生成,生成 intermediate representation(IR)

综合部分:

  1. 代码优化,机器无关优化,生成优化后的 IR
  2. 优化生成,生成目标代码

静态分析

静态分析是指不执行代码,推导程序的行为,分析程序的性质。

控制流(control flow):程序执行的流程

数据流(Data flow):数据在控制流上的传递

过程内分析和过程间分析

过程内分析仅在函数内部进行分析,过程间分析会考虑函数调用时参数传递和返回值的数据流和控制流。

Go 编译器优化

做编译器优化是用户无感知的,重新编译即可获得性能提升。这种优化的通用性很强。

编译优化的思路是一种 tradeoff,用编译时间来换取更高效的机器码。

一些 beast mode包括:

  • 函数内联
  • 逃逸分析
  • 默认栈大小调整
  • 边界检查消除
  • 循环展开

函数内敛

内敛是指将被调用函数的函数体的副本替换到调用位置上,同时重写代码以反映参数的绑定。

这样做可以消除函数的调用开销,例如传递参数,保存寄存器等。

将过程间的分析转换为过程内分析,帮助其他优化,例如逃逸分析。

这么做的缺点就是函数体会变的庞大,编译生成的 Go 镜像也变大了。

逃逸分析

逃逸分析是指分析代码指针的动态作用域:指针在何处可以被访问。

从对象分配处出发,沿着代码控制流,观察对象的数据流:

  • 作为参数传递给其它函数
  • 传递给全局变量
  • 传递给其他的 goroutine
  • 传递给已经逃逸的指针指向的对象

如果满足以上某条,则该指针逃逸

对于未逃逸的对象,可以将其在栈上分配。这样可以降低 GC 负担。