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

545 阅读11分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的4篇笔记。

课程导学链接

juejin.cn/post/709597…

课程PPT链接

bytedance.feishu.cn/file/boxcnR…

课程中有不少概念性内容,且导学链接中已经总结得十分完善,照抄其内容并无意义,本篇笔记中仅对其中重点部分以及个人不太熟悉的部分进行整理摘抄以加强记忆以及后期复习,更加全面的内容可以查看PPT或课程视频。

性能优化

image.png

自动内存管理

概念性内容直接参考课程导学链接,已经总结得非常完善了。下面对追踪和引用计数这两种自动内存管理方式做介绍。

追踪垃圾回收

追踪垃圾回收其实就是对当前程序运行的内存做一个扫描,标记出当前程序没有任何生命周期内的指针指向的内存,因为Golang中不存在指针运算,所以这个内存一定不可能再被用户程序使用,于是就可以对该内存进行回收。

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

  • 标记根对象:静态变量、全局变量、常量、线程栈等

  • 标记:从根对象出发,找到所有可达对象

  • 清理:所有不可达对象。根据对象的生命周期,使用不同的标记和清理策略。

    • 将存活对象复制到另外的内存空间(Copying GC)

    • 将死亡对象的内存标记为可分配(Mark-Sweep GC)

    • 移动并整理存活对象(Mark-Compact GC)

分代GC(Generational GC)

分代GC其实就是前面所说的根据对象生命周期不同来制定不同的回收策略,所依赖的最主要特性其实就是大多数动态分配的内存都很快不再使用了,往往都抗不过几轮GC,我们将其称为年轻代,年轻代的数量一般都并不大(因为死的快),所以直接采用Copying collection,回收速度就很快;另外生命周期很长的内存,如果每次都复制那开销就太大了,则采用Mark-sweep GC的方式回收。

引用计数(Reference counting)

引用计数的方式与前面追踪式回收区别较大,其核心思路是对于每个动态分配的内存都维护一个引用数目,代表程序中有多少个变量正在使用这个内存(学过C++的同学应该很熟悉智能指针的概念,智能指针就是采用的引用计数的方式来实现的自动回收)。自然而然地,当引用数目为0时,就没有变量可以访问这个地址了,于是可以回收。

优点:

  • 内存管理的操作被平摊到程序的执行过程中
  • 内存管理不需要知道Runtime的实现(C++智能指针实现原理)

缺点:

  • 维护引用数目的额外开销较大:需要通过原子操作保证对引用计数的操作的原子性和可见性
  • 无法回收环形数据结构(Weak reference可解决此问题)
  • 内存开销:每个对象都需要引入额外的内存空间来存储数目
  • 回收大对象时仍可能发生暂停(牵一发而动全身),如下图

Golang的垃圾回收机制

(本节内容均摘抄自给定链接,非原创,重在整合信息方便阅读学习)

Golang中使用的是改进过的追踪垃圾回收方法——无分代、不整理、并发的三色标记法。

三个特性

  • 不整理:对象整理优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于tcmalloc,基本上没有碎片问题。 并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于tcmalloc的现代内存分配算法,对象整理不会带来实质性的性能提升。
  • 无分代:分代GC的优势来自于分代假设(大多数对象生命周期很短),但是Go语言编译器会进行逃逸分析,大部分存活时间短的对象都直接分配在栈上了,不需要垃圾回收来参与,所以分代不会带来直接优势。
  • 并发:Go 的垃圾回收器与用户代码并发执行,使得 STW(Stop The World,程序暂停) 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。

三色标记法

普通的追踪垃圾回收有一个致命的问题,那就是进行垃圾回收的时候整个程序会暂停(STW)。三色标记法对标记阶段进行了改进,可以在不暂停程序的情况下完成对象的可达性分析。

顾名思义,三色标记法将对象分成了三种颜色:

  • 白色:未搜索的对象,在回收周期开始时所有对象都是白色,在回收周期结束时所有的白色都是垃圾对象
  • 灰色:正在搜索的对象,但是对象身上还有一个或多个引用没有扫描
  • 黑色:已搜索完的对象,所有的引用已经被扫描完

三色标记法属于增量式GC算法,回收器首先将所有的对象着色成白色,然后从GC Root出发,逐步把所有“可达”的对象变成灰色再到黑色,最终所有的白色对象即是“不可达”对象。

具体的实现如下:

  • 初始时所有对象都是白色对象
  • GC Root对象出发,扫描所有可达对象并标记为灰色,放入待处理队列
  • 从队列取出一个灰色对象并标记为黑色,将其引用对象标记为灰色放入队列
  • 重复上一步骤,直到灰色对象队列为空
  • 此时所有剩下的白色对象就是垃圾对象

优点:

  • 不需要暂停整个程序进行垃圾回收

缺点:

  • 如果程序垃圾对象的产生速度大于垃圾对象的回收速度时,可能导致程序中的垃圾对象越来越多而无法及时收集
  • 线程切换和上下文转换的消耗会使得垃圾回收的总体成本上升,从而降低系统吞吐量

(以上只是对三色标记法基础内容的一个介绍,实际还有不少细节,如读写屏障技术等,感兴趣可以参考给出的链接进一步了解)

Go内存管理及优化

Go的内存管理其实是个相对复杂的话题,对于New Gopher来说我觉得可以简单了解下大概是什么情况就可以了,等到深入使用确实需要用到这方面的具体内容的时候再深入学习。

在学习Go的内存管理之前需要先熟悉Go所采用的GMP调度模型,具体可以阅读GMP 原理与调度 · Go语言中文文档 (topgoer.com),简单了解GMP模型大致意思即可。

Go的内存管理有两个大的特点,一是分块,二是缓存。

分块

  • 调用系统调用mmap()向OS申请一大块内存,例如4MB

  • 先将内存分为大块,例如8KB,称为mspan

  • 继续将大块分为特定大小的块,用于对象分配

  • Noscan mspan:分配不包含指针的对象——GC无需扫描

  • Scan mspan:分配包含指针的对象——GC需要扫描

缓存

正如数据访存可以使用缓存来提高效率一样,内存分配同样可以使用缓存来提高分配效率,具体缓存结构如下。

内存管理优化——Balanced GC

优化的基础:

  • 对象分配的频率非常高(每秒GB级别)

  • 小对象居多

  • Go的分配比较耗时

    原因:分配路径很长。(存疑,缓存虽然全部miss路径很长,但是缓存的基础就是整体上miss的概率较低,在程序局部性较强的情况下能节省时间)

优化方案——Balanced GC

核心就是为每个G都绑定一大块内存(1KB),称为goroutine allocation buffer(GAB),用于noscan类型的小对象分配(<128B),使用三个指针维护GAB,如下图所示

本质与问题

本质就是将很多个小对象的分配合并成一次大对象(GAB)的分配,有点类似于内存池的概念。

这样的分配方案有个问题:会导致内存的延迟释放(假想GAB中仅有一个小对象并不释放,整个GAB都不会被Go回收)。

解决办法是:检测到GAB中存活对象的大小低于一定的阈值时,将这些对象移动到另外的GAB中,提高GAB中存活对象总体积占比。(相当于用Coping GC管理小对象)

最终性能收益:高峰期CPU usage降低4.6%,核心接口时延下降4.5%~7.7%。

编译器和静态分析

这一章节的内容主要是给后面编译器优化的内容做铺垫,简单介绍编译流程和静态分析概念。

编译流程

静态分析

过程间分析与过程内分析

Go编译器优化

编译器优化本质是用编译时间来换执行时间,课堂上主要介绍了两种方式,分别是函数内联和逃逸分析。

函数内联

简单理解就是把函数体内容直接插入到函数调用处,同时还要把实际参数写进去,减小函数调用的开销。

优点:

  • 消除函数调用开销,如传递参数、保存寄存器等
  • 将过程间分析转换为过程内分析,帮助其他优化,例如逃逸分析

缺点:

  • 函数体变大,对于指令缓存不友好

相当于总的指令条数变多了,原来n个函数调用只有固定的一个函数那个多的指令,内联之后需要n个函数量的指令。

  • 编译生成的Go镜像变大

综合优缺点,通过函数规模来控制是否内联,最终一般都是正向收益。

不过Golang中函数内联比较保守,还有一些语言特性(如interface和defer)限制了函数内联。为此字节推出了Beast mode,调整了Golang的内联策略降低了函数调用的开销并增加了逃逸分析的机会,编译时间约增加了10%,Go语言镜像略微增大。

逃逸分析

Beast Mode效果:高峰期CPU usage降低9%,时延降低10%,内存使用降低3%。

P.S. 动态分配的内存不一定会动态分配,而函数内部的局部变量也并不一定就是分配在栈上的,具体可以查看这个例子golang: Escape analysis and interfaces (npat-efault.github.io)

课后

  1. 从业务层和语言运行时层进行优化分别有什么特点?

    参考性能优化部分。

  2. 从软件工程的角度出发,为了保证语言 SDK 的可维护性和可拓展性,在进行运行时优化时需要注意什么?

    两个方面:一是接口一致性,另一个就是要充分测试。

  3. 自动内存管理技术从大类上分为哪两种,每一种技术的特点以及优缺点有哪些?

    追踪垃圾回收和引用计数,简单来说追踪垃圾回收会有程序暂停问题,引用计数的额外开销比较大,具体可以参考自动内存管理部分。

  4. 什么是分代假说?分代 GC 的初衷是为了解决什么样的问题?

    分代假说:大多数对象声明斗气都很短。

    解决的问题:为不同的生命周期长度的对象制定不同的回收策略,从而提高回收性能。

  5. Go 是如何管理和组织内存的?

    分块+缓存。

  6. 为什么采用 bump-pointer 的方式分配内存会很快?

    bump-pointer概念的大致意思就是分配内存只需要指针的移动,不需要额外的管理,与编译器在栈上分配内存有点类似,只需要执行很简单的边界检查以及指针加法操作,所以运行很快。

  7. 为什么我们需要在编译器优化中进行静态代码分析?

    可以知道更多程序的性质,用于后续的优化。

  8. 函数内联是什么,这项优化的优缺点是什么?

    参见函数内联一节。

  9. 什么是逃逸分析?逃逸分析是如何提升代码性能的?

    参见逃逸分析一节。