Go内存管理与编译器优化 | 青训营笔记

110 阅读8分钟

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

1. 内存管理

动态内存

  • 程序在运行时根据需求动态分配的内存:malloc()

自动内存管理(垃圾回收):由程序语言的运行时系统回收动态内存

  • 避免手动内存管理,专注于实现业务逻辑
  • 保证内存使用的正确性和安全性: double-free problem, use-after-free(uaf) problem三个任务
  • 多线程的内存分配器

垃圾收集器/内存分配器的任务(如果内存分配器的free操作不由使用者完成,那么就是垃圾收集器)

  • 为新对象分配空间
  • 找到存活对象
  • 回收死亡对象的内存空间

mgc.go 中,是这么描述go垃圾收集器的

go的垃圾收集器:并行收集器。JVM的垃圾收集器是STW的,也就是SerialGC和ParallelGC共同存在的。

  • “mutator threads” are threads actually trying to perform a heap modification during the marking phase 可以理解为对堆进行操作的用户线程

  • go不采取分带内存管理,基于写屏障的标记-清理算法。不带压缩。

  • go的内存分配是用固定大小分割P内存块,以达到最小化的内存和尽量少的加所操作。

  • 安全性问题

由于go的concurrentGC,只有部分线程会被STW(mark过程是绝对要STW的,这里的并发指的是,并不是gc开始时,所有活跃的goroutine都要进行gc,而是挑选一部分goroutine进行gc。那么没有STW的goroutine,会对mark过程影响)

所以mark过程不仅要开启写屏障,还要进行STW,在一切标记完毕后,还要再检查一次。

  • 评价gc算法的指标

STW对用户体验影响极大。 如果STW时间过长,用户会感觉很难受。

1.1 追踪垃圾回收

go就采取这种垃圾回收策略。

  • 定位GCroots,也就是对象指针引用处。
  • 找到GCroots直接可达对象,并找到这些对象的传递闭包。完成mark过程。

最后是清理过程,go采取的是mark-sweep GC 清理过程是为了下一次分配服务的。我们只管分配,gc只管收集,所以gc的收集要让下一次的分配更舒服

CopyGC:将存活对象拷贝到新的内存空间:适用于一次GC中,存活对象较少,并且存活的都是小对象。

Mark-sweep:使用空闲列表管理,适用于一次GC存活对象较多(不会频繁产生内存碎片)。并且存活的都是大对象

Mark compact 对存活对象进行原地压缩,使得内存碎片被清理干净。适用于指针碰撞的分配内存方式。但是go不采取指针碰撞。

1.2 分代gc

go的runtime不采取分代gc,但是jvm采取

理论就是:年轻的新对象很小,并且很容易被回收。年长的对象很大,很难被回收。因此采取不同的回收策略

1.3 引用计数

python使用的就是引用计数。无法解决循环依赖。但是python也有自己的一套,用于探测循环依赖情况。

2. go的内存分配策略

垃圾回收 是为了 更好的内存分配

讲完垃圾回收,就要看看go的内存分配策略

创建对象时,必须伴随的是go的内存分配。

go提前把内存进行了分块。这是内存分配器的一个实现手段,现代语言的内存分配器往往采取了更丰富的手段,以尽可能保证分配效率。

可以看到,gc为了内存分配服务,内存分配也要给gc服务。

noscan区和scan区的分配,无异减少了gc的负担。

而固定大小块的分配,又是有效控制了内存的对齐,和碎片产生。并且这种查找固定大小块的分配,也使得分配十分迅速。

这个分配方式应该是csapp里指定的一种方式。

go的span缓存

由于go以span为单位管理内存,那么定位一个对象,就是定位一个span。

那么可不可以对span进行缓存呢?

mcache缓存者不同大小的span块,首先就根据span大小,去mcache查找。如果找不到再去mcentral。

所以mcache是mcentral的一个缓存(mcentral就对应OS的一页[mmap])

缓存采取LRU应该,因为新分配的块会优先存在于mcache。并且这个page是延迟释放的。 就算mcentral没有被使用到,也不会立即释放。而是缓存于mcentral,等待分配

go内存分配体系的缺点

通过pprof探测,runtime.mallocgc 这个函数的调用非常频繁,而且耗时。因为对象分配必须是频繁的,所以我们只能降低这个函数的耗时,通过缩短调用链完成耗时的缩短,是我们重要的优化方向

优化:BalancedGC

这个优化很像jvm内存优化之TLAB

即线程私有的一块内存分配区。

在go中就是goroutine私有的一片内存区域,GAB。

  • GAB中,对象的分配使用 指针碰撞。
  • GAB用于不会被GC的对象,比如基本类型的分配与存放等。这样不需要回收,也不需要清理内存空间,分配直接就增加top指针,直到分配不下去。同时,由于是goroutine私有的内存区域,也不用加锁
  • GAB本身作为一个span,接受go的内存回收策略

缺点

  • GAB中只要有一个对象仍在使用,那么将导致GAB无法被释放

3. 编译器和静态分析

我们能做的主要是后端编译器部分的优化。

静态分析

这个玩意,就好像读源码...

有的编译器自己就会读源码,在语义分析的时候,直接计算出已知的内容

而我们进行静态分析的目的是,绘制出程序控制流图。

有了控制流图,可以模拟数据在其上的传递。根据数据传递优化代码

控制流图可能是嵌套的,因为方法存在嵌套的调用。因此根据控制流图中是否有方法嵌套,又分为过程内分析和过程间分析

当然,既然进行了静态分析,那就意味着编译期就可以获取到结果。

如果运行结果需要在运行期反射获取,我们就无法进行优化。因为结果是什么都不确定,也就是数据在程序控制流中的传递是不确定的。

4. go编译器优化

边界检查消除,往往会被编译器进行自行优化。也就是编译器可以分得清一个变量是否位于一定的范围内,从而避免反复的检查。(csapp第五章)。

而循环展开也是一个在现代流水线CPU下优化性能的好方式。

函数内联化是现代编译器的一个重要优化指标。jvm也在做函数内联方面的优化。

最大的优点就是,减少了函数调用的开销。函数调用需要的额外开销:传参,依赖于寄存器,甚至需要在栈上分配。把被调用者保存寄存器安排到栈上,结束后还要弹出,加大内存访问。最后还要保存返回值等。这些开销都是除去直接执行汇编代码的额外开销。将函数内联化,可以减少这些开销。

缺点

  • 函数体变大,不利于指令缓存。尤其是递归函数,内联展开后,出现大量重复代码,使得整个函数臃肿难读。
  • go镜像变大,这是自然,因为提取出函数的目的就是复用,现在你给人家打开了,必然会加大代码量。
  • 不能进行打桩操作:打桩是基于函数调用的。如果内联之后,打桩就没用了。

可以适当降低内联优化的标准,使得更多函数都可以被内联优化。以提高效率。

逃逸分析同样也是JVM的优化策略之一。

主要体现在栈上分配,对象栈上分配,不仅malloc容易(类似指针碰撞)。回收也容易,不用任何gc算法。就可以回收。开的快去的快,何乐不为?甚至,进一步的,可以把矢量分解。都在栈上分配了,那么对结构的操作,也是类似直接对局部变量的操作,因此可以把结构体拆成组成其的标量,存储为局部变量

逃逸分析主要耗时在如何分析一个对象是否逃逸。是否逃逸的标准是 对象是否进会被当前函数的栈帧内部访问。而内联化可以减少函数的调用,进而避免某一个对象被其他函数栈帧引用。