高性能Go语言发行版优化与落地实践|青训营笔记

104 阅读7分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记。

Chapter1. 自动内存管理

1.1 主要任务

  • 为新对象分配内存空间
  • 找到当前仍存活的对象
  • 回收已死对象的内存空间

1.2 相关概念

  • Mutator:业务线程,在此会进行分配新对象,修改对象指向关系

  • Collector:GC线程,找到存活对象并回收死亡对象的内存空间

  • Serial GC:单线程回收,只有一个collector

  • Parallel GC:多线程并行回收,即有多个collector参与回收(这里是多个gc线程并行,而不是gc线程与业务线程并发,注意区别)

  • Concurrent GC:支持业务线程与GC线程并发,这里需要注意的是在GC执行的过程中业务线程可能改变了引用关系、创建新对象等操作,GC线程应感知业务线程进行的操作。(常用的就是三色标记法)

    图解

1.3 GC算法评价

  • 安全性(Safety):基本要求——不能回收存活的对象
  • 吞吐率(Throughput):(程序执行总时间 - GC时间)/ 程序执行总时间
  • 暂停时间(Parse Time):一般就是stop the world的时间开销
  • 内存开销(Space overhead):GC元数据开销。

1.4 追踪垃圾回收(可达性分析)

  • 对象被回收的条件:当前对象在可达性分析中不可达

  • 标记根对象(根结点枚举)

    静态变量、全局变量、常量、线程栈等

  • 遍历关系图

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

关系图

  • 清理所有不可达对象。

Copying GC

类似于JVM中的标记——复制算法,将内存分为两片区域,只使用一片区域,当需要进行GC时,将当前区域仍然存活的对象复制到另一片区域,然后直接清理掉原区域的边界内存即可。

copying GC

Mark-Sweep GC

标记——清除算法,一般也适用于新生代GC。这里使用free list这种数据结构来管理空闲内存。

Mark-Sweep GC

Mark-Compact GC

标记——整理算法,一般用于老年代GC,在需要进行GC时在内存区域原地整理对象,将仍然存活的对象整理到首部,然后直接清理边界内存。这里需要注意的是引用地址的改变。

1.5 引用计数法

每个对象都有一个与之关联的引用数目,当新增一个指针引用该对象时引用计数+1,当这个引用消失时引用技术-1,任何引用计数为0的对象我们认为是不可达的。

图示

优点

  • 将内存管理的操作平摊到程序执行过程中(维护引用计数)
  • 内存管理不需要了解runtime的具体实现细节:比如C++的智能指针

缺点

  • 维护引用计数的开销较大,需要通过原子操作来保证在多线程条件下对引用计数操作的原子性和可见性。
  • 无法回收环形数据结构(一般可使用weak reference来解决)。
  • 引用计数增加了内存开销,每个对象都需要引入额外的内存空间来存储引用计数。
  • 回收内存时如果碰到了一系列引用链的回收,可能会导致较长的暂停。

环形数据结构

Chapter2. Go内存管理及优化

2.1 Go内存分配

Go提前将内存分块,调用系统调用mmap()向操作系统申请一大块内存,比如4MB。

  • 先将内存划分为大块,比如8KB的块,称作mspan
  • 再将大块继续划分成为特定大小的小块来用于对象分配
  • noscan mspan:分配不包含指针的对象(GC在做可达性分析时候就不用往下扫描)
  • scan mspan:分配包含指针的对象

图

在分配对象时根据对象的大小来选择最合适的块返回。

2.2 Go内存分配缓存

cache

  • 每一个p(Processor)都包含一个mcache用于为绑定于p上的g(goroutine)快速分配对象
  • 一个mcache管理一组mspan,可以用于快速分配
  • 当mcache的mspan分配完后,会向mcentral申请带有未分配块的mspan
  • 当mspan中空间都未被分配,mspan将会被缓存在mcentral中,而不是立即释放并归还给操作系统

2.3 Go内存管理优化

分配

在我们实际的应用场景中,对象分配是很高频的操作,每秒钟分配GB级别的内存,而且从上面的图中可以看出,在分配对象中,大多数都是小对象。

而我们之前提到的Go内存分配比较耗时:g -> m -> p -> mcache -> mspan -> memory block -> return pointer,这一点我们可以通过pprof数据来佐证。

pprof

2.4 字节跳动的优化方案——Balance GC

  • 每一个g(goroutine)都绑定一大块内存,称作goroutine allcation buffer(GAB)
  • 每个GAB都用作noscan类型的小对象分配(<128B)
  • 我们只需用三个指针来维护GAB的操作:baseendtop

GAB

分配

  • 基于指针碰撞(Bump Pointer)风格来实现对象分配,每次分配仅需移动指针,简单高效
  • GAB对于Go内存管理而言是一个大对象,所以其本质是即将多个小对象的分配合并成一次大对象的分配

存在的问题

由于GAB中存放多个小对象,这种对象分配方式可能会导致内存被延迟释放。比如GAB中仅仅有一个小对象,此时因为存在对象仍不能释放内存,造成延迟释放的问题。

解决方案:基于copying GC的算法来管理小对象。

  • 当GAB剩余容量总大小超过一定阈值时,我们将GAB中存活的对象复制到另外的GAB中,从而直接释放原来的GAB,避免内存泄漏。

copying

带来的性能收益

性能收益

Chapter3. 编译器与静态分析

3.1 编译器的结构

结构

3.2 静态分析

  • Concept:不执行程序代码,推导程序的行为,分析程序的性质。
  • 控制流(Control flow):程序执行的流程
  • 数据流(Data flow):数据在控制流程的传递
  • 通过分析控制流和数据流,我们可以知道更多关于程序的性质,从而根据这些性质来优化代码。

3.3 过程内分析与过程间分析

  • 过程内分析(Intra- procedural analysis):仅在函数内部进行分析
  • 过程间分析(Inter- procedural analysis):考虑函数调用时参数传递和返回值的数据流和控制流

Chapter4. Go编译器优化

4.1 函数内联

  • Concept:将被调用函数的函数体(callee)的副本替换到调用位置(caller)上,同时重写代码反映参数的绑定。
  • 优点:

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

函数内联究竟能多大程序影响性能?

我们使用micro-benchmark来验证一下:

1

2

res

  • 缺点:

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

4.2 Beast Mode

  • Go语言内联受到的限制比较多

    • 语言特性,比如interface(接口是非直接已知类型),defer等,限制了函数内联
    • 内联策略比较保守
  • Beast mode:调整函数的内联策略,使得更多函数被内联

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

    • Go镜像大小增加了10%
    • 编译时间增加

性能收益

性能收益

4.3 逃逸分析

  • Concept:分析代码中指针的动态作用域

  • 大致思路

    • 从对象分配处出发,沿着控制流观察对象的数据流

    • 若发现指针p在当前作用域s:

      • 作为参数传递给其他参数
      • 传递给全局变量
      • 传递给其他的goroutine
      • 传递给已逃逸的指针指向的对象
    • 则指针p指向的对象逃逸出s,反之则没有逃逸出s

  • 逃逸分析的优化:未逃逸的对象可以在栈上分配

    • 对象在栈上分配和回收都很快,仅仅需要移动栈顶指针
    • 减少在堆上的分配,降低GC负担