这是我参与「第五届青训营 」笔记创作活动的第4天
本节课将主要介绍关于高性能 Go 语言发行版优化的内存管理优化,分享自动内存管理与 Go 内存管理知识,提供可行性的优化建议。
1.自动内存管理
动态内存
程序在运行时根据需求动态分配的内存:malloc()
自动内存管理(垃圾回收):由程序语言运行时系统管理动态内存,保证了内存使用的正确性和安全性,避免如double-free problem和use-after-free problem
三个任务:为新对象分配空间,找到存活对象,回收死亡对象的内存空间。
Garbage collector(GC)
相关概念
- Mutator: 业务线程(用户启动的),分配新对象,修改对象指向关系
- Collector: GC 线程,找到存活对象,回收死亡对象的内存空间
- Serial GC:只有一个Collector
- Parallel GC: 并行 GC,支持多个Collector同时回收
- Concurrent GC: 并发 GC,Mutator和Collector可以同时执行
Collectors必须感知对象指向关系的改变:在Concurrent GC过程中,标记存活对象的同时,被标记对象可能同时又多指向了一个新对象:
评价GC算法
- 安全性:不能回收存货的对象
- 吞吐率:1-GC时间/程序执行总时间(越高越好)
- 暂停时间:stop the world(STW) 业务是否感知
- 内存开销:GC元数据开销
追踪垃圾回收
对象被回收条件:指针指向关系不可达的对象
步骤:
①标记根对象:静态变量、全局变量、常量、线程栈
②标记:找到可达对象(求指针指向关系的传递闭包)
③清理:所有的不可达对象。有三种清理方式:
- 存活对象复制到另外的内存空间(Copying GC);
- 将死亡对象的内存标记为可分配;移动并整理存活对象(Mark-sweep GC)
- 移动并整理存活对象(Mark-compact GC) (Compact GC(原地整理对象),即将存活变量移动到堆的头部,在移动后的位置的末尾开始做内存分配)
分代GC
分代假说:每个对象都有年龄(经历过GC的次数),很多对象分配出来后很快就不使用了,也即most objects die young
针对年轻和老年的对象,指定不同的GC策略,降低整体内存管理的开销
年轻代:常规的对象分配,由于存活对象很少,可以采用copying collection
老年代:对象趋于一直活着,反复复制的开销较大,可以使用mark-sweep collection
引用计数
每个对象都有一个与之关联的引用数目,对象存活的条件即为当且仅当引用数大于0
优点:内存管理的操作被平摊到程序执行的过程中,内存管理不需要了解runtime的实现细节
eg. C++智能指针
缺点:
- 维护开销较大,需要使用原子操作保证对引用计数操作的原子性和可见性
- 无法回收环形数据结构
- 内存开销:每个对象都引入的额外内存空间存储引用数目
- 回收内存时仍然可能引发暂停
2.Go语言内存管理及优化
分块
目标:为对象在堆上分配内存
提前内存分块:
- 调用系统调用
mmap()向OS申请一大块内存,例如4MB - 先将内存划分为大块,例如8KB,称作mspan
- 再将大块继续划分成为特定大小的小块,用于对象分配
- noscan mspan:分配不包含指针的对象——GC不需要扫描
- scan mspan:分配包含指针的对象——GC需要扫描
缓存
以g(goroutine)作为根节点,p节点上含有一个数据结构(mcache),mcache存放了一组mspan,等待快速分配,如果mcache中的maspan已满,则在mcentral中寻找一个符合大小要求的mspan。
Go内存管理优化
对象分配是非常高频的操作:每秒分配GB级别的内存,小对象的占比较高
Go的内存分配路径:
g -> m -> p -> mcache -> mspan -> memory block -> return pointer
优化方案 Balanced GC
每个G都绑定一大块内存(1KB),称作goroutine allocation buffer(GAB)
GAB用于noscan类型的小对象分配<128B
使用三个指针维护GAB:base, end, top
Bump pointer(指针碰撞)风格对象分配,无需和其他分配请求互斥,分配动作简单高效
if top + size <= end {
addr := top
top += size
return addr
}
GAB对于Go内存管理来说是一个大对象
本质:将多个小对象的分配合并成一次大对象的分配
问题:GAB的对象分配方式会导致内存被延迟释放
方案:移动GAB中存活的对象
- 当GAB总大小超过一定阈值时,将GAB中存活的对象复制到另外分配的GAB中
- 原先GAB可以释放,避免内存泄露
- 本质:用copying GC的算法管理小对象
3.编译器和静态分析
编译器的结构
静态分析
静态分析:不执行程序代码,推导程序的行为,分析程序的性质。
控制流(Control flow):程序执行的流程
数据流(Data flow):数据在控制流上的传递
如本过程,通过静态分析,得到了本函数数据流会返回4,则函数被优化为return 4
过程内分析和过程间分析
过程内分析:仅在过程内部进行分析
过程间分析:考虑过程调用时传递和返回值得数据流和控制流
只有通过数据流分析得知i的具体类型,才能够知道foo()是哪个类型的方法,根据i的类型可知调用了A.foo(),再进行控制流分析。————联合求解
4.Go编译器优化
编译优化的思路:
- 场景:面向后端长期执行任务
- 代价:用编译时间换取更高效的机器码 Beast mode:
- 函数内联
- 逃逸分析
- 默认栈大小的调整
- 边界检查消除
- 循环展开
- ...
函数内联(inlining)
内联:将被调用函数的函数体(callee)的副本替换到调用位置(caller)上,同时重写代码以反映参数的绑定
优点:消除函数调用的开销,将过程间分析转化为过程内分析
缺点:函数体变大,对instruction cache(icache)不友好,编译生成的Go镜像变大
逃逸分析
分析代码中指针的动态作用域:指针在何处可以被访问
大致思路:
- 从对象分配处出发,沿着控制流,观察对象的数据流
- 若发现指针p在当前作用域s:
- 作为参数传递给其他函数
- 传递给全局变量
- 传递给其他的goroutine
- 传递给已逃逸的指针指向的对象
- 则指针p指向的对象逃逸出了s,反之则没有逃逸出s
优化:函数内联减少了逃逸对象的数目,未逃逸对象可以在栈上分配
总结
本节课程以GC作为引入,介绍了一部分GC的算法,然后代入到Go语言的GC机制中去,讨论了Go语言长GC链的优缺点,并且根据实际业务场景、编译器后端的处理逻辑,介绍了GAB和Beast Mode两个优化思路,扩展了对于Go语言底层的认识。