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

139 阅读11分钟

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

一、本堂课重点内容

Go内存管理

  • 自动内存管理
  • 内存管理及优化

编译器

  • 编译器和静态分析
  • 编译器优化

二、详细知识点介绍

性能优化指的是提升软件系统的处理能力,减少不必要的消耗,充分发掘计算机算力。

性能优化可以带来很多收益,包括但不限于:

  • 提升用户体验
  • 资源高效利用

自动内存管理

自动内存管理也被称为垃圾回收,指的是由程序语言的运行时系统管理动态内存(malloc)。 使用自动内存管理可以:

  • 避免手动内存管理,专注于实现业务逻辑
  • 保证内存使用的正确性和安全性,在内存分配中有两个常见的问题:
    • double-free problem:多次回收同一块内存
    • use-after-free problem:在回收之后又使用该内存

而使用自动内存管理,可以很大程度上避免该类问题。

那么自动内存管理的任务是什么?

可以简单的总结为三点:

  1. 为新对象分配空间
  2. 找到存活对象
  3. 回收死亡对象的内存空间

相关概念

  • Mutator:业务线程,分配新对象,修改对象指向关系,对应任务一
  • Collector:GC线程,找到存活对象,回收死亡对象的内存空间,对应任务二和任务三
  • Serial GC:只有一个Collector

image.png

  • Parallel GC:支持多个Collectors同时回收的GC算法

image.png

  • Concurrent GC:Mutator(s)和Collector(s)可以同时执行

image.png

image.png

注意: Serial GC和Parallel GC在GC阶段,Mutator都被暂停(Stop The World),而Concurrent GC可以同时执行。并且Concurrent GC在GC时Collectors必须感知对象指向关系的改变。

如何评价GC算法?

  • 安全性(Safety):不能回收存活的对象,这是基本要求
  • 吞吐率(Throughout)1GC时间程序执行总时间1-\frac{GC时间}{程序执行总时间},也就是花在GC上的时间越少,吞吐量越高
  • 暂停时间(Pause Time):Stop The World(STW),这里需要注意控制时间长度,业务感知很重要
  • 内存开销(Space overhead)GC元数据开销,也就是GC所需要消耗的内存空间

追踪垃圾回收

被回收的对象是指针指向关系不可达的对象,因为不可达就代表没有被依赖。

垃圾回收的过程由以下几点组成:

  • 标记根对象

    根对象包括静态变量、全局变量、常量、线程栈等,很容易想到因为其从程序一开始运行就被整个程序依赖,因此根对象就是他们。

  • 标记:找到可达对象

    从根对象出发,找到所有可达的对象,也就是求指针指向关系的传递闭包

  • 清理:所有不可达对象

    该步骤有三种策略,如下所述:

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

      image.png
    • 将死亡对象的内存标记为“可分配”(Mark-sweep GC

      如下图所示,使用free list管理空闲内存

      image.png
    • 移动并整理存活对象(Mark-compact GC

      如下图所示,原地整理对象 image.png

根据对象的生命周期,需要使用不同的标记和清理策略。

分代GC

分代GC(Generational hypothesis):most objects die young,其认为很多对象在分配出来之后很快就不再使用了,而每个对象都有年龄,也就是经过GC的次数。

它的目的就是针对年轻和老年的对象,制定不同的GC策略,从而降低整体内存管理的开销。

还有重要的一点就是不同年龄的对象处于heap的不同区域。

image.png

年轻代(Young generation)

对于年轻代,也就是常规的对象分配,由于其假设,存活对象很少,因此可以采用Copying GC,这种情况下,GC的吞吐率很高。

老年代(Old generation)

对于老年代,其中的对象趋向于一直存活着,若反复复制,开销将会很大,因此适合采用Mark-sweep GC

引用计数

其特点是每个对象都有一个与之关联的引用数目,因此该对象存活的条件就是其引用数目大于0。

优点

  • 内存管理的操作被平摊到程序的执行过程中
  • 内存管理不需要了解runtime的实现细节,C++的智能指针(smart pointer)就是类似的方法

缺点

  • 维护引用计数的开销很大,因为需要通过原子操作保证对引用计数操作的原子性和可见性
  • 无法回收环形数据结构,也就是weak reference。

image.png

  • 内存开销:每个对象都引入额外的存储空间来存储引用数目。
  • 回收内存时依然可能引发暂停

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。

go-tcmalloc.png

内存管理优化

需要知道的一点是:对象分配是非常高频的操作,每秒分配GB级别的内存是很常见的。并且在对象分配中,小对象的占比相对较高,并且内存分配的流程比较耗时。

graph LR
g --> m
m --> p
p --> mcache
mcache --> mspan
mspan --> memory-block
memory-block --> return-pointer
pprof表示对象分配函数真是调用最频繁的函数之一。

优化方案Balanced GC

  • 将每个G都绑定一大块内存(1KB),称为goroutine allocation buffer(GAB)
  • GAB用于noscan类型的小对象分配:< 128B
  • 使用三个指针维护GAB:base,end,top
  • Bump pointer(指针碰撞)风格对象分配
    • 无须和其他分配请求互斥
    • 分配动作简单高效

image.png

GAB对于Go内存管理来说是一个对象。

image.png

其本质就是将多个小对象的分配合并成一次大对象的分配

不过也有缺点,就是GAB的对象分配方式会导致内存被延迟释放(GAB中的一块小内存被释放了,并不会将GAB释放),解决办法就是移动GAB中的存活对象,当GAB总大小超过一定的阈值时,将GAB中存活的对象复制到另外分配的GAB中,这样原先的GAB就可以释放了,从而避免内存泄漏,从本质上看,也就是使用Copying GC来管理小对象

编译器和静态分析

编译器的结构

编译器是重要的系统软件,它的作用主要有两个:

  • 识别符合语法和非法的程序
  • 生成正确且高效的代码

对于整个编译器结构来说,其一般被分为分析部分(前端)综合部分(后端)

image.png

分析部分(前端)

分析部分主要的工作如下:

  • 词法分析,生成词素(lexeme)
  • 语法分析,生成语法树
  • 语义分析,收集类型信息,进行语义检查
  • 中间代码生成,生成intermediate representation(IR)

综合部分(后端)

综合部分主要工作如下:

  • 代码优化,机器无关优化,生成优化之后的IR
  • 代码生成,生成目标代码

而我们要做的就是编译器后端的优化。

静态分析

静态分析指的是不执行程序代码,推导程序的行为,从而分析程序的性质。

其中有两个相关的概念:

  • 控制流(Control Flow):程序执行的流程
  • 数据流(Data Flow):数据在控制流上的传递

通过分析控制流和数据流,我们可以知道更多关于程序的性质,从而根据这些性质来优化代码。

image.png image.png

过程内分析和过程间分析

过程内分析(Intra-procedural analysis) 指的是仅在函数内部进行分析。

过程间分析(Inter-procedural analysis) 则要考虑过程调用时参数传递和返回值的数据流和控制流。

而过程间分析是一个问题,先看下面左边这一张图:

image.png image.png

i的具体类型未知时,需要根据数据流分析i的具体类型,而分析具体类型时,又产生了新的控制流因为AB都实现了接口I里的foo()函数,因此过程间分析需要同时分析控制流和数据流,也就是联合求解,是比较复杂的。

而根据控制流分析,可以知道其调用的是Afoo()方法。

Go 编译器优化

编译器优化带来的好处:

  1. 用户无感知,重新编译就可以获得性能收益
  2. 通用性优化

而编译器现状是:

  • 采用的优化少
  • 编译时间短,没有进行较复杂的代码分析和优化

编译优化的思路

  • 常见:面向后端长期执行任务
  • Tradeoff:用编译时间换取更高效的机器码

Beast Mode(字节自研)

  • 函数内联
  • 逃逸分析
  • 默认栈大小调整
  • 边界检查消除
  • 循环展开
  • ......

函数内联

内联指的就是将被调用函数的函数体(callee)的副本替换到调用位置(caller)上,同时重写代码以反映参数的绑定。

内联的优点如下:

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

有优点必然也有缺点,其缺点如下:

  • 函数体变大,对于instruction cache不友好
  • 编译生成的Go镜像变大

不过在大多数情况下,内联都是正向优化

在Golang函数中内联受到的限制比较多

  • 语言特性,例如interface、defer等,限制了函数的内联
  • 内联的策略非常保守

逃逸分析

逃逸分析指的是分析代码中指针的动态作用域,以确定指针在何处可以被访问。

其大致思路如下:

  • 从对象分配处出发,沿着控制流,观察对象的数据流
  • 若发现指针p在当前作用域s下有以下行为:
    • 作为参数传递给其他参数
    • 传递给全局变量
    • 传递给其他的goroutine
    • 传递给已逃逸的指针指向的对象
  • 那么就说指针p指向的对象逃逸出了s,反之则没有逃逸出s

Beast Mode

其为字节自研的编译优化方法。

函数内联

对于函数内联,其调整了函数内联的策略,从而使得更多函数被内联。

其好处就是:

  • 降低函数调用的开销
  • 增加了其他优化的机会:逃逸分析

不过有些许的开销

  • Go镜像增加了10%10\%左右
  • 编译时间增加

逃逸分析

对于逃逸分析,由于函数内联拓展了函数边界,因此更多的对象都不会逃逸出来。

其所做的优化是将未逃逸的对象在栈上分配,因为对象在栈上分配和回收很快,只需要移动sp指针即可。而减少heap上的内存分配,可以降低GC的负担。

三、实践练习例子

本节课程没有给用于实战的代码,不过给出了一些优化后的收益效果。

对于内存管理方面,Balanced GC的性能收益见下图

image.png

其中高峰期CPU的使用降低了4.6%,核心接口时延下降了4.5%~7.7%

而对于Beast Mode的CPU性能收益见下图

image.png

其中高峰期CPU的使用下降了9%,而时延下降了10%

下图为Beast Mode的内存性能收益

image.png

其中内存的使用降低了3%

四、课后个人总结

本节课程讲解了很多关于内存方面的知识,并且介绍了相关的优化方法,还简单解释了Balanced GC的优化。除此之外还讲解了很多编译器方面的知识,关于编译器的优化从函数内联和逃逸分析进行了简单的介绍。

我认为,要想进一步深入,还是得看一些相关的书籍以及文献,另外,课程中有一句话说的很好,也就是分析问题的方法不仅适用于Go语言,其他语言的优化也是同样适用的。

五、引用参考