Go语言内存管理 | 青训营笔记

95 阅读6分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第4天。今天主要学习了自动内存管理的一些基本算法,编译器优化的策略,以Go语言为例了解了两个解决方案Balanced GCBeast mode

1. 自动内存管理

1.1 基本概念:

  • 动态内存:程序运行时根据需求分配内存, 如malloc()
  • 自动内存管理(垃圾回收) :由程序语言的运行时系统来管理动态内存,保证内存使用的正确性安全性(c或c++里的两个经典问题 double-free,use-after-free);
  • 三个任务:为新对象分配空间、找到存活对象、回收死亡对象的内存空间;

| 概念 | Mutator | Collector | Serial GC | Parallel GC | Concurrent GC | | ---------- | ------------ | ------------ | ------------ | ------------ || ---------- | | 含义 | 业务线程 | GC线程 | 只有一个collector | 支持多个collectors同时回收GC算法 | 多个mutator和collector可同时执行 |

程序运行时 Mutator负责分配新对象并修改对象的指向关系、Collector负责找到存活对象并回收死亡对象的内存空间;

Serial GC和Parallel GC算法均不支持Mutator和Collector同时执行,Collector时暂停Mutator,而Concurrent GC支持Mutator和Collector同时执行;

Concurrent GC实现起来比较困难,必须要感知对象指向关系的改变。

1.2 GC算法的评价:

  • 安全性,基本要求
  • 吞吐率,花在GC上时间的占比
  • 暂停时间, 业务是否感知
  • 内存开销,GC元数据开销

1.3 常见的GC技术:

  • 追踪垃圾回收:指针指向关系不可达的对象为死亡对象;

    1. 标记根对象(静态变量、全局变量、常量、线性栈等以及根对象指针所指向的对象);
    2. 标记可达对象(从根对象出发,找到所有可达对象,既求指针指向关系的传递闭包);
    3. 清理不可达对象(Copying GC:将存活对象复制到另外的内存空间,清空原区域;Mark-sweep GC:使用free-list管理空闲内存,在内存分配时直接从free-list取空间进行分配,将不可达对象的空间加入到free-list中;Mark-compact GC:将存活对象拷贝到原空间的开头处,清理后面的空间);

    分代GC(Generational GC): 给每个对象加一个了年龄(经历过GC的次数),针对年轻和老年的对象放在不同的区域,指定不同的GC策略,对于年轻代(大部分都死亡了)采用Copying GC代价比较小,对于老年代(趋于一直存活)可采用Mark-sweep GC,减少反复copy的开销,碎片太多了可以compact下;

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

    优点: 内存管理的操作被平摊到程序执行过程中,内存管理不需要了解runtime的实现细节,与之耦合性较低,可以用单独的库来实现比如c++的smart pointer;

    缺点: 维护引用计数(用到原子操作)开销较大,环形数据结构无法回收,每个对象需要额外的内存空间存储引用数目,回收大内存时仍然可能会引发暂停;

1.4 Go内存分配

1.4.1 Go内存分配的两个思想

分块: 提前将内存分块:通过系统调用mmap()向OS申请一大块内存,将内存分成大块(mspan),再将大块继续划分成特定大小的小块用于对象分配,noscan mspan: 分配不含指针的对象,scan mspan: 分配含指针的对象,GC仅扫面含指针的对象;最后根据对象大小,选择合适的块返回;

缓存: 一个goroutine执行时需绑定一个p,p上包含一个mcache用于快速分配,mcache管理着一组mspan,当mcache中的mspan分配完毕,则向mcentral申请未被分配的mspan,当mspan中没有分配的对象时会缓存在mcentral中,而不是立刻释放并归还给OS;

1.4.2 Balanced GC

存在的问题:

  • 高频,每秒GB级别的操作
  • 小对象占比高
  • 分配路径长,很耗时 优化方案 Balanced GC: 针对上述问题,该优化方案将多个小内存分配合并成一次大内存分配,具体操作如下:
  1. 每个g都绑定一大块内存(1 KB),称为goroutine allocation buffer(GAB)
  2. GAB用于noscan类型的小对象分配:< 128 B;
  3. 使用三个指针维护GAB:base,end,top;
  4. Bump pointer(指针碰撞)风格对象分配,也就是直接移动top指针并返回:无需和其他分配请求互斥、分配动作简单高效; 该方案存在问题:GAB的对象分配方式会导致内存被延迟释放,GAB内只要有一个小对象存活便不会被释放;

用copying GC思想进一步优化:移动GAB中存活的对象,当GAB总大小超过一定阈值时,将GAB中存活的对象复制到另外分配的GAB中,原GAB可被释放;

2. 编译器优化

2.1 静态分析

控制流(Control flow): 分析程序执行的流程

int a = 30
int b = 9 - ( a / 5 )
int c
c = b * 4

if (c > 10) {
    c = c - 10
}
return c * (60 / a)

转化成控制流图:

graph TD
subgraph  
id1["int a = 30"] --> id2["int b = 9 - ( a / 5 )"] --> id3["int c"] --> id4["c = b * 4"]
end
subgraph  
id5["if (c > 10)"]
end
subgraph  
id6["c = c - 10"]
end
subgraph  
id7["return c * (60 / a)"]
end
id4["c = b * 4"] --> id5["if (c > 10)"] --> id6["c = c - 10"] --> id7["return c * (60 / a)"]
id5["if (c > 10)"] --> id7["return c * (60 / a)"]

数据流(Data flow): 分析数据在控制流上的传递, 将输入填入变量,去分析执行过程; 比如将a=30代入上述代码,可发现该段代码返回的结果唯一且确定为4,因此可用return 4代替上述一段代码;

通过分析控制流和数据流,可以直到更多关于程序的性质,进而进行优化;

过程内分析: 仅在函数内部进行分析;

过程间分析: 考虑函数调用时参数传递和返回值的数据流和控制流;

2.2 Go编译器优化

2.2.1 存在的问题

现状: 为减少编译时间,没有进行复杂的代码分析和优化;

思路: 牺牲部分编译时间换取更高效的机器码;

2.2.2 Beast mode

函数内联: 将被调用的函数的副本替换到调用位置上并调整参数,以达到消除函数调用开销并减少过程间分析的目的; 但是会导致函数体变大,编译生成的Go镜像变大; 因此不能无休止的内敛,需要根据一些策略来指导

  • 调用和被调函数的规模 Go语言的interface,defer等特性限制了函数内联; Beast mode调整了函数内敛的策略,使更多函数被内联;

逃逸分析: 分析代码中指针的动态作用域:指针在何处被访问,看是否跑出了它的作用域;

  1. 从对象分配处出发,沿着控制流,观察对象的数据流;
  2. 若发现指针p在当前作用域s:(作为参数传递给其他函数,传递给全局变量、传递给其他的goroutine、传递给已逃逸的指针指向的对象);
  3. 指针p指向的对象逃逸出s,反之没有逃逸出s; Beast mode的函数内联拓展了函数的边界,因次很多对象不逃逸了;由于未逃逸的对象可以在栈上分配,栈上的对象分配和回收很快,可以降低GC的负担。