这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天
从各个层面来看性能优化:
-
业务层优化
- 针对特定业务场景,具体问题具体分析
- 容易获得较大性能提升
-
语言运行时优化
- 解决更通用的性能问题
- 考虑更多场景
- Tradoffs
-
数据驱动
- pprof
- 依靠数据而不是猜测
- 首先优化最大瓶颈
本次课程讲解了内存管理策略,主要是 GC 方面的管理及其优化。这些算法的思想并不复杂,通常是一些通过设置cache、预分配等简单策略来实现的优化。在 Go 编译器优化方面,函数内联通过减小复杂的过程间分析,使得程序运行效率得以提升,通过逃逸分析,使得未逃逸变量分配到栈上来使得运行效率得以提升。
自动内存管理
自动内存管理是指程序语言运行时系统管理动态内存。避免了手动内存管理,专注于实现业务逻辑。它需要保持内存使用的正确性和安全性:double-free problem, use-after-free problem。
它专注于三个任务:
- 为新对象分配空间
- 找到存活对象
- 回收死亡对象的内存空间
相关概念:
-
Mutator:业务线程,分配新对象,修改对象指向关系。
-
Collector:GC 线程,找到存活对象,回收死亡对象的内存空间。
-
Serial GC:只有一个 collector。
-
Parallel GC:支持多个 collectors 同时回收的 GC 算法。
-
Concurrent GC:mutator 和 collectors 可以同时执行。
-
- Collector 必须感知对象指向关系的改变。
-
如何评价 GC 算法:
- 安全性(Safety):不能回收存活的对象「基本要求」
- 吞吐率(Throughput):1 - GC时间/程序执行总时间「花在 GC 上的时间」
- 暂停时间(Pause time):Stop the world (STW)「业务是否感知」
- 内存开销(Space overhead)「GC 元数据开销」
-
追踪垃圾回收(Tracing garbage collection)
-
引用计数(Reference counting)
追踪垃圾回收过程
条件:指针指向关系不可达的对象可被回收。
变量引用关系是一张有向图,出发点是「静态变量」,「全局变量」,「常量」,「线程栈」等。从它们出发,找到所有的可达对象。清理时,有如下几种方式:
- 将存活对象复制到另外的内存空间(Copying GC)
- 将死亡对象的内存标记为「可分配」(Mark-sweep GC),即使用 free-list 管理空闲内存
- 移动并整理存活对象(Mark-compact GC),即原地整理
对于不同生命周期的对象,使用不同的标记和清理策略。
分代 GC
分代 GC 即 Generational GC,基于的假设是大多数对象在分配出来后很快就不再使用了。定义每个对象的年龄是经历过的 GC 次数。这样做的目的是对不同生命周期的对象,制定不同的 GC 策略,降低整体管理开销。
不同年龄的对象处于 heap 的不同区域
- 年轻代,使用常规的对象分配,由于存活对象少,所以使用 Copying GC。这样做 GC 吞吐率高。
- 老年代,对象一直存在,反复复制开销较大,所以使用 Mark-sweep GC。
引用计数
类似一个有向图,依赖关系即图中的连边,对象存活的条件是其引用数大于 0。
这样做的优点在于内存管理的操作被平摊到程序执行过程中,内存管理不需要了解 runtime 的实现细节。
而缺点也有很多:
- 维护引用计数的开销较大:通过原子操作保证对引用技术操作的原子性和可见性
- 无法回收环形数据结构 —— weak reference
- 回收内存时依然可能引发暂停
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 的算法管理小对象。
编译器和静态分析
编译器
编译器是重要的系统软件,它可以识别符合语法和非法,从而生成正确且高效的代码。
大致流程如下:
分析部分:
- 词法分析,生成词素
- 语法分析,生成语法树
- 语义分析,收集类型信息,进行语义检查
- 中间代码生成,生成 intermediate representation(IR)
综合部分:
- 代码优化,机器无关优化,生成优化后的 IR
- 优化生成,生成目标代码
静态分析
静态分析是指不执行代码,推导程序的行为,分析程序的性质。
控制流(control flow):程序执行的流程
数据流(Data flow):数据在控制流上的传递
过程内分析和过程间分析
过程内分析仅在函数内部进行分析,过程间分析会考虑函数调用时参数传递和返回值的数据流和控制流。
Go 编译器优化
做编译器优化是用户无感知的,重新编译即可获得性能提升。这种优化的通用性很强。
编译优化的思路是一种 tradeoff,用编译时间来换取更高效的机器码。
一些 beast mode包括:
- 函数内联
- 逃逸分析
- 默认栈大小调整
- 边界检查消除
- 循环展开
函数内敛
内敛是指将被调用函数的函数体的副本替换到调用位置上,同时重写代码以反映参数的绑定。
这样做可以消除函数的调用开销,例如传递参数,保存寄存器等。
将过程间的分析转换为过程内分析,帮助其他优化,例如逃逸分析。
这么做的缺点就是函数体会变的庞大,编译生成的 Go 镜像也变大了。
逃逸分析
逃逸分析是指分析代码指针的动态作用域:指针在何处可以被访问。
从对象分配处出发,沿着代码控制流,观察对象的数据流:
- 作为参数传递给其它函数
- 传递给全局变量
- 传递给其他的 goroutine
- 传递给已经逃逸的指针指向的对象
如果满足以上某条,则该指针逃逸
对于未逃逸的对象,可以将其在栈上分配。这样可以降低 GC 负担。