这是我参与「第五届青训营 」笔记创作活动的第4天
一、本堂课重点内容
Go内存管理
- 自动内存管理
- 内存管理及优化
编译器
- 编译器和静态分析
- 编译器优化
二、详细知识点介绍
性能优化指的是提升软件系统的处理能力,减少不必要的消耗,充分发掘计算机算力。
性能优化可以带来很多收益,包括但不限于:
- 提升用户体验
- 资源高效利用
自动内存管理
自动内存管理也被称为垃圾回收,指的是由程序语言的运行时系统管理动态内存(malloc)。 使用自动内存管理可以:
- 避免手动内存管理,专注于实现业务逻辑
- 保证内存使用的正确性和安全性,在内存分配中有两个常见的问题:
- double-free problem:多次回收同一块内存
- use-after-free problem:在回收之后又使用该内存
而使用自动内存管理,可以很大程度上避免该类问题。
那么自动内存管理的任务是什么?
可以简单的总结为三点:
- 为新对象分配空间
- 找到存活对象
- 回收死亡对象的内存空间
相关概念
- Mutator:业务线程,分配新对象,修改对象指向关系,对应任务一
- Collector:GC线程,找到存活对象,回收死亡对象的内存空间,对应任务二和任务三
- Serial GC:只有一个Collector
- Parallel GC:支持多个Collectors同时回收的GC算法
- Concurrent GC:Mutator(s)和Collector(s)可以同时执行
注意: Serial GC和Parallel GC在GC阶段,Mutator都被暂停(Stop The World),而Concurrent GC可以同时执行。并且Concurrent GC在GC时Collectors必须感知对象指向关系的改变。
如何评价GC算法?
- 安全性(Safety):不能回收存活的对象,这是基本要求
- 吞吐率(Throughout):,也就是花在GC上的时间越少,吞吐量越高
- 暂停时间(Pause Time):Stop The World(STW),这里需要注意控制时间长度,业务感知很重要
- 内存开销(Space overhead):GC元数据开销,也就是GC所需要消耗的内存空间
追踪垃圾回收
被回收的对象是指针指向关系不可达的对象,因为不可达就代表没有被依赖。
垃圾回收的过程由以下几点组成:
-
标记根对象
根对象包括静态变量、全局变量、常量、线程栈等,很容易想到因为其从程序一开始运行就被整个程序依赖,因此根对象就是他们。
-
标记:找到可达对象
从根对象出发,找到所有可达的对象,也就是求指针指向关系的传递闭包。
-
清理:所有不可达对象
该步骤有三种策略,如下所述:
-
将存活对象复制到另外的内存空间(Copying GC)
-
将死亡对象的内存标记为“可分配”(Mark-sweep GC)
如下图所示,使用free list管理空闲内存
-
移动并整理存活对象(Mark-compact GC)
如下图所示,原地整理对象
-
根据对象的生命周期,需要使用不同的标记和清理策略。
分代GC
分代GC(Generational hypothesis):most objects die young,其认为很多对象在分配出来之后很快就不再使用了,而每个对象都有年龄,也就是经过GC的次数。
它的目的就是针对年轻和老年的对象,制定不同的GC策略,从而降低整体内存管理的开销。
还有重要的一点就是不同年龄的对象处于heap的不同区域。
年轻代(Young generation)
对于年轻代,也就是常规的对象分配,由于其假设,存活对象很少,因此可以采用Copying GC,这种情况下,GC的吞吐率很高。
老年代(Old generation)
对于老年代,其中的对象趋向于一直存活着,若反复复制,开销将会很大,因此适合采用Mark-sweep GC
引用计数
其特点是每个对象都有一个与之关联的引用数目,因此该对象存活的条件就是其引用数目大于0。
优点
- 内存管理的操作被平摊到程序的执行过程中
- 内存管理不需要了解runtime的实现细节,C++的智能指针(smart pointer)就是类似的方法
缺点
- 维护引用计数的开销很大,因为需要通过原子操作保证对引用计数操作的原子性和可见性。
- 无法回收环形数据结构,也就是weak reference。
- 内存开销:每个对象都引入额外的存储空间来存储引用数目。
- 回收内存时依然可能引发暂停
Go 内存管理及优化
内存分配的目标就是为对象在heap上分配内存。
内存管理
分块
在Golang中,其提前将内存分块,过程如下:
- 调用系统调用
mmap()向OS申请一大块内存,比如4MB - 先将内存划分为多个大块,例如8KB,被称为
mspan - 再将大块继续划分成特定大小的小块,用于对象分配。根据不同粒度,可以划分出不同大小的小块
- noscan mspan:分配不包含指针的对象,此时GC不用扫描
- scan mspan:分配包含指针的对象,此时GC需要扫描
然后根据对象的大小,选择最合适的块将其返回即可
缓存
Go语言的内存管理参考了TCMalloc,其中T表示Thread,C表示Caching
首先根据Go语言的GMP调度模型(G为goroutine,M为Machine也叫thread内核线程,P为Processor处理器,P调度G到M上执行),每一个P都会拥有一个mcache用于快速分配内存(因此不用加锁),也就是给P上的G分配内存,其中mcache管理了一组mspan,当mspan内存分配完毕后会向mcentral(M共享,加锁访问)申请带有未分配块的mspan,当mspan中的块都未被分配时(可能是对象被回收了),mspan会被缓存到mcentral中,而不是立刻释放并归还给OS。
内存管理优化
需要知道的一点是:对象分配是非常高频的操作,每秒分配GB级别的内存是很常见的。并且在对象分配中,小对象的占比相对较高,并且内存分配的流程比较耗时。
graph LR
g --> m
m --> p
p --> mcache
mcache --> mspan
mspan --> memory-block
memory-block --> return-pointer
优化方案Balanced GC
- 将每个G都绑定一大块内存(1KB),称为goroutine allocation buffer(GAB)
- GAB用于noscan类型的小对象分配:< 128B
- 使用三个指针维护GAB:base,end,top
- Bump pointer(指针碰撞)风格对象分配
- 无须和其他分配请求互斥
- 分配动作简单高效
GAB对于Go内存管理来说是一个对象。
其本质就是将多个小对象的分配合并成一次大对象的分配。
不过也有缺点,就是GAB的对象分配方式会导致内存被延迟释放(GAB中的一块小内存被释放了,并不会将GAB释放),解决办法就是移动GAB中的存活对象,当GAB总大小超过一定的阈值时,将GAB中存活的对象复制到另外分配的GAB中,这样原先的GAB就可以释放了,从而避免内存泄漏,从本质上看,也就是使用Copying GC来管理小对象。
编译器和静态分析
编译器的结构
编译器是重要的系统软件,它的作用主要有两个:
- 识别符合语法和非法的程序
- 生成正确且高效的代码
对于整个编译器结构来说,其一般被分为分析部分(前端) 和 综合部分(后端)。
分析部分(前端)
分析部分主要的工作如下:
- 词法分析,生成词素(lexeme)
- 语法分析,生成语法树
- 语义分析,收集类型信息,进行语义检查
- 中间代码生成,生成intermediate representation(IR)
综合部分(后端)
综合部分主要工作如下:
- 代码优化,机器无关优化,生成优化之后的IR
- 代码生成,生成目标代码
而我们要做的就是编译器后端的优化。
静态分析
静态分析指的是不执行程序代码,推导程序的行为,从而分析程序的性质。
其中有两个相关的概念:
- 控制流(Control Flow):程序执行的流程
- 数据流(Data Flow):数据在控制流上的传递
通过分析控制流和数据流,我们可以知道更多关于程序的性质,从而根据这些性质来优化代码。
过程内分析和过程间分析
过程内分析(Intra-procedural analysis) 指的是仅在函数内部进行分析。
过程间分析(Inter-procedural analysis) 则要考虑过程调用时参数传递和返回值的数据流和控制流。
而过程间分析是一个问题,先看下面左边这一张图:
在i的具体类型未知时,需要根据数据流分析i的具体类型,而分析具体类型时,又产生了新的控制流因为A和B都实现了接口I里的foo()函数,因此过程间分析需要同时分析控制流和数据流,也就是联合求解,是比较复杂的。
而根据控制流分析,可以知道其调用的是A的foo()方法。
Go 编译器优化
编译器优化带来的好处:
- 用户无感知,重新编译就可以获得性能收益
- 通用性优化
而编译器现状是:
- 采用的优化少
- 编译时间短,没有进行较复杂的代码分析和优化
编译优化的思路
- 常见:面向后端长期执行任务
- Tradeoff:用编译时间换取更高效的机器码
Beast Mode(字节自研)
- 函数内联
- 逃逸分析
- 默认栈大小调整
- 边界检查消除
- 循环展开
- ......
函数内联
内联指的就是将被调用函数的函数体(callee)的副本替换到调用位置(caller)上,同时重写代码以反映参数的绑定。
内联的优点如下:
- 消除函数调用开销,例如传递参数、保存寄存器等
- 将过程间分析转换为过程内分析,帮助其他优化,例如逃逸分析
有优点必然也有缺点,其缺点如下:
- 函数体变大,对于instruction cache不友好
- 编译生成的Go镜像变大
不过在大多数情况下,内联都是正向优化
在Golang函数中内联受到的限制比较多
- 语言特性,例如interface、defer等,限制了函数的内联
- 内联的策略非常保守
逃逸分析
逃逸分析指的是分析代码中指针的动态作用域,以确定指针在何处可以被访问。
其大致思路如下:
- 从对象分配处出发,沿着控制流,观察对象的数据流
- 若发现指针p在当前作用域s下有以下行为:
- 作为参数传递给其他参数
- 传递给全局变量
- 传递给其他的goroutine
- 传递给已逃逸的指针指向的对象
- 那么就说指针p指向的对象逃逸出了s,反之则没有逃逸出s
Beast Mode
其为字节自研的编译优化方法。
函数内联
对于函数内联,其调整了函数内联的策略,从而使得更多函数被内联。
其好处就是:
- 降低函数调用的开销
- 增加了其他优化的机会:逃逸分析
不过有些许的开销
- Go镜像增加了左右
- 编译时间增加
逃逸分析
对于逃逸分析,由于函数内联拓展了函数边界,因此更多的对象都不会逃逸出来。
其所做的优化是将未逃逸的对象在栈上分配,因为对象在栈上分配和回收很快,只需要移动sp指针即可。而减少heap上的内存分配,可以降低GC的负担。
三、实践练习例子
本节课程没有给用于实战的代码,不过给出了一些优化后的收益效果。
对于内存管理方面,Balanced GC的性能收益见下图
其中高峰期CPU的使用降低了4.6%,核心接口时延下降了4.5%~7.7%
而对于Beast Mode的CPU性能收益见下图
其中高峰期CPU的使用下降了9%,而时延下降了10%
下图为Beast Mode的内存性能收益
其中内存的使用降低了3%
四、课后个人总结
本节课程讲解了很多关于内存方面的知识,并且介绍了相关的优化方法,还简单解释了Balanced GC的优化。除此之外还讲解了很多编译器方面的知识,关于编译器的优化从函数内联和逃逸分析进行了简单的介绍。
我认为,要想进一步深入,还是得看一些相关的书籍以及文献,另外,课程中有一句话说的很好,也就是分析问题的方法不仅适用于Go语言,其他语言的优化也是同样适用的。