go优化 | 青训营笔记

54 阅读10分钟

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

1.自动内存管理

1.1 自动内存管理

动态内存是程序在运行时根据需求动态分配的内存(malloc)。往往需要程序员手动申请、手动释放。

自动内存管理(垃圾回收):由程序语言的运行时系统管理动态内存,使程序员可以避免手动内存管理,专注于实现业务逻辑。同时能保证内存使用的正确性和安全性(程序员经常会两次释放同一片内存或使用释放过的内存,从而带来漏洞和隐患)。

GC的三个任务:

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

go语言可以在垃圾回收的过程中同时(Concurrently)进行Mutator threads的调用。

  • Mutator:业务线程,分配新对象,修改对象指向关系
  • Collector:GC线程,找到存活对象,回收死亡对象的内存空间
  • Serial GC:只有一个collector,GC执行时会暂停其他所有线程,GC完成后恢复其它线程
  • Parallel GC:支持多个collectors同时回收的GC算法,会暂停,暂停时多个collector同时运行
  • Concurrent GC:mutators和collectors可以同时执行。由于gc执行时用户可能创建新对象,所以必须感知对象指向关系的改变。

如何评价GC算法:

  • 安全性:不能回收存活的对象(最基本要求)。
  • 吞吐率:1−GC时间程序执行总时间1-\frac{GC时间}{程序执行总时间}1−程序执行总时间GC时间 (程序花在GC上的时间占比越少越好)
  • 暂停时间:业务是否感知到暂停
  • 内存开销:GC元数据开销(存储GC进程的空间)

1.2 追踪垃圾回收(Tracing garbage collection)

对象被回收的条件:指针指向关系不可达的对象

GC步骤:

  1. 标记根对象:将静态变量、全局变量、常量、线程栈等对象标记为存活,同时将其作为根节点。
  2. 找到并标记可达对象:求指针指向关系的传递闭包,从根对象出发,找到所有可达对象。
  3. 清理所有不可达对象:清理操作可以选择不同的策略。copying GC策略将存活对象复制到另外的内存空间,然后清理当前空间;Mark-sweep GC策略将死亡对象的内存标记为"可分配",同时用一个free list的链表管理起来,后续分配直接使用free list中的内存块;Mark-compact GC策略移动并整理存活对象,减少空间中的内存碎片。

1.3 分代GC(Generational GC)

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

分代假说提供了一种如何选择清理策略的方法。分代假说认为大多数对象分配出来之后很快就不再被使用了,因此分代GC对每个对象记录一个年龄,年龄值为对象经历GC的次数。然后将年轻对象和老年对象分别存于不同的空间中。对于年轻代的对象,每轮GC存活的对象很少,因此使用copying collection(需要复制的对象数不多,能保证GC的吞吐率很高),将仍然生存的对象复制到老年代区域;对于老年代的对象,其趋向于一直存活,因此如果每轮GC都复制则开销较大,于是可以采用mark-sweep collection的方案(碎片多时也可以使用mark-compact GC策略)。

1.4 引用计数

每个对象都有一个与之关联的引用数目,当且仅当引用数大于0时对象存活。

优点:

  • 内存管理操作被平摊到程序执行过程中。
  • 内存管理不需要了解runtime的实现细节,只需要记录引用数(例如C++的智能指针)。

缺点:

  • 维护时开销大:必须使用原子操作保证计数操作的原子性和可见性。
  • 无法回收环形数据结构(可解决,例如weak reference策略)。
  • 内存开销:每个对象都需要额外内存空间记录其引用计数。
  • 回收内存时依然可能引发暂停:假设引用链足够长,需要将整条链一次性回收。

2.go内存管理及优化

分块

go语言会提前调用系统调用mmap()向OS申请一大块内存,例如4MB。然后将此块内存分为较大块,例如8KB,称作mspan。在将mspan继续划分为特定大小(例如8B、16B、24B……由于对齐的机制,所有对象的大小都是8B的倍数)的小块,用于对象分配。mspan会有noscan mspanscan mspan之分。noscan mspan分配不包含指针的对象,因此GC时不需要扫描对象内部,直接回收;scan mspan分配包含指针的对象,因此GC时需要扫描对象内部指针,并判断所指区域是否存活。

缓存

go的分配器借鉴了TCMalloc(thread caching)分配器的实现,在内存分配中增加了多级缓存以加快分配速度。

2022-05-12 00-19-09 的屏幕截图.png

g代表goroutine。m为分配器,分配器从p中寻找未分配的内存块。每个p包含一个mcache用于快速分配,mcache管理一组mspan(图中mcache的每行代表一个mspan),根据对象大小,找到一个大小合适的mspan,最后从mspan中找到一个未分配的块。如果mspan中没有为分配的块,则从mcentral中寻找一个存在为分配块的mspan,与mcache中的同大小的mspan交换。如果mcentral中也没有空余,则向mheap申请。

mcentral中每次和mcache交换都得到一个全被分配的mspan,之所以图中会有未分配区域,是因为交换之后内存被释放。当mcentral中的一个mspan全部未分配时,mcentral不会立刻将内存还回操作系统,而是遵守一定的策略。

go内存管理优化

  • 对象分配是非常高频的操作:每秒分配GB级别的内存。
  • 小对象占比较高:大小16B居多,基本都小于80B。
  • go内存分配比较耗时:分配路径长,需要经过g->m->p->mcache->mspan->memory block->return pointer。经过pprof分析可知,对象分配的函数(runtime.mallocgc)是最频繁调用的函数之一。 因此我们需要对对象分配进行优化。

为每个g绑定一大块内存(1KB),称作goroutine allocation buffer(GAB)。GAB用于noscan类型的小对象(<128B)分配。使用三个指针(base:GAB的开始;end:GAB的末尾;top:未使用的地址的开头,即下一次将分配top到top+sizeof(obj)的位置)维护GAB。这是一种指针碰撞(bump pointer)风格的对象分配方式,其优点为无需和其它分配请求互斥(每一个协程都有一个GAB)以及分配动作简单高效(分配路径短,且分配逻辑简单)。

  • GAB对于Go内存管理来说是一个大对象。
  • 本质是将多个小对象的分配合并成一次大对象的分配。

问题:GAB的对象分配方式会导致内存被延迟释放。

解决方案:移动GAB中存活的对象。当GAB总大小超过一定阈值时,将GAB中存活的对象复制到另外分配到GAB中,然后就可以释放原先的GAB。其本质就是使用copying GC的算法管理小对象。

3.编译器和静态分析

3.1 编译器的结构

2022-05-12 19-27-48 的屏幕截图.png

编译器是重要的系统软件,其作用是识别符合语法和非法的程序(前端)和生成正确且高效的代码(后端)。

3.2 静态分析

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

  • 控制流(Control flow):程序执行的流程,例如程序会走哪条if分支。
  • 数据流(Data flow):数据在控制流上的传递,记录不同变量在不同位置是的大小。

通过分析控制流和数据流,我们可以知道更多关于程序的非平凡特性,并根据这些性质指导编译器优化代码。

3.3 过程内分析和过程间分析

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

为什么过程间分析是个问题?

分析时并没有数据输入,所以我们并不知道变量的具体类型与大小。而根据变量的不同,可能产生不同的控制流。因此过程间分析需要同时分析控制流和数据流,联合求解,比较复杂。

4.go编译器优化

4.1 函数内联

什么是内联?

一般的函数调用会记录当前状态,然后跳转到函数处,执行完函数之后返回之前记录的位置。而内联函数编译时将被调用函数的函数体(callee)的副本替换到调用位置(caller)上,同时重写代码以及反映参数的绑定。

  • 消除函数调用的开销,例如传递参数、保存寄存器等。
  • 将过程间分析转化为过程内分析,帮助其它优化,例如逃逸分析。
  • 代码实际上顺序执行,并没有调用函数,会使caller和编译形成的文件变大,需要更大的cache存储caller以及更大的磁盘空间存储编译结果,相当于用空间换时间,需要考虑callercallee的规模后再使用。

4.2 Beast Mode

为什么Go函数内联受到的限制较多?

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

Beast mode:调整函数内联的策略,使更多函数被内联。

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

开销:

  • go镜像增加约10%。
  • 编译时间增加。

4.3 逃逸分析

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

大致思路:

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

  2. 若发现指针p在当前作用域s,进行以下操作:

     作为参数传递给其它函数
     传递给全局变量
     传递给其它goroutine
     传递给已逃逸的指针指向的对象
    复制代码
    复制代码
    

则指针p指向的对象逃逸出s,反之则没有逃逸出s。

Beast mode中,函数内联拓展了函数的边界,使更多对象不逃逸。

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

  • 对象在栈上分配和回收很快,只需要移动sp。
  • 减少变量在堆中的分配,降低GC负担。

引用

go语言优化|青训营笔记

【后端专场 学习资料一】第五届字节跳动青训营