Go 语言内存模型和编译器优化 | 青训营

83 阅读9分钟

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

1 性能优化的层面

业务层:

  • 针对特定场景,具体问题具体分析
  • 容易获得较大的性能收益

runtime (Go SDK) 优化:

  • 解决通用的性能问题(如内存分配、编译等)
  • 要考虑更多场景,要根据场景做权衡

在优化时,要依靠数据,而不是猜测,首先优化最大的性能瓶颈。

1.1 保证性能优化的质量

在保证接口稳定的前提下改进具体实现。
测试:覆盖尽可能多的场景,驱动开发。
隔离:通过选项控制是否开启优化。
文档:做了什么、没做什么、能达到什么样的效果,让使用者知道自己有没有必要开启这项优化。

2 自动内存管理

由 runtime 管理动态内存,帮我们为新对象分配空间、回收不需要的内存空间。

基本概念

Mutator: 业务线程,制造和修改对象
Collector: GC 线程

Serial GC: 只有一个 collector
Parallel GC: 有多个 collector
Concurrent GC: 业务线程和 GC 线程同时执行

Concurrent GC 的挑战: 必须要感知到一个已标记的对象产生的新对象

清理垃圾这件事其实只有两步:

  1. 找垃圾
  2. 丢垃圾

2.1 谁是垃圾?

垃圾的定义就是:没人用的东西。
因此,在发现垃圾这件事上,我们的重点就在于怎样确定哪些对象是没用的。
为此,我们有两种常见的方法:

Tracing GC - 追踪垃圾回收算法

它也被称为可达性收集法。

基本原理就是:

  1. 标记根对象(如常量、栈空间中的对象等)
  2. 从根对象出发,找到所有的可达对象
  3. 那些不可达的,就都是垃圾了

Reference Counting GC - 引用计数算法

每个对象都有一个与之关联的引用数目 refCounter, 多一个指针引用就 +1,少一个指针引用就 -1
当一个对象 refCounter <= 0 的时候,它就是个没人要的垃圾。

这看起来很不错:

  • 内存管理的操作被平摊到了程序执行过程中
  • 实现的过程与 runtime 耦合较小,可以作为一个单独的库来实现(如 C++ 中的智能指针)

但问题也来了:

  • 维护引用计数的开销较大(refCounter 的操作需要保证原子性和可见性)
  • 无法不可达的回收环形数据结构
  • refCounter 本身占一定内存

2.2 丢垃圾

通常来说,清理垃圾有三种方法,他们都很好理解。

Mark-sweep GC: 将垃圾对象的内存标记为可分配

这种清理垃圾的方法应该是最快的,因为它其实根本没有清理,只是单纯地声明这个地方可以放新对象了。

但清理的时候轻松了,要用的时候就没那么容易了。
我们都知道,对于新来的对象,如果想要找个安家的位置,那必须得挑选一个合适自己大小的空闲空间。

不同的对象变成垃圾的时间肯定不一样,这一通操作清理完以后,会把可用空间变得东一块西一块,甚至有可能出现一种极端的情况:就算总体的可用空间满足对象的需求,但因为被分散到各个角落,所以无法分配。

这种情况我们叫做内存碎片化

Compact GC: 移动并整理存活对象

和我们平时打扫卫生一样,我们在丢垃圾的时候,也会顺手把房间整理一下。
要解决碎片化的问题,要实现的就是把存活的对象放到内存中的一端。
这样,另外一端就全部都是垃圾,可以用来分配新的对象。

这种操作让我们的可用内存和空闲内存都是连续的,使得我们在分配新对象时可以进行指针碰撞

指针碰撞指的是:如果我们把内存当作一个线性表,用一个指针cur指向线性表中第一个可用空间的地址,
那么如果要分配一个新的size的对象,就可以直接得知它的起始位置是 cur,分配完完这个对象以后,下一个可用的位置 cur = cur + size

但这样做其实还有一个问题:整理的过程需要时间。
其实不难理解,扫描完一次垃圾之后的内存空间肯定是东一块西一块,如果想让所有可用的对象乖乖地贴在一端,那肯定得经过一些交换等操作。

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

和上一种算法相比,它就简单粗暴的多了。
这种算法在清理垃圾的时候,直接找一块干净的内存空间(现在没有存对象的),然后把前面标记的不是垃圾的对象全部丢进去。
这样,原来的那块空间就全部都是垃圾了。

虽然还是需要复制,但至少它整理的过程少了交换这一步骤。

但这种丢法也有它的缺点,比如说:必须找一块干净的内存空间,有点奢侈

既然这些丢垃圾的方法各有不同的缺点,那么我们在实际中肯定要根据对象的特点来选择合适的清理策略。

混合清理策略 —— 分代收集算法

这个算法很多编程语言的 runtime 都在用,特别是 JVM。

分代收集算法认为:大部分对象都属于早年夭折的类型。

这也很好理解,毕竟我们现在的编程语言都喜欢认为万物皆对象,你的一个函数里可能就造出了好几个对象,但最终只返回那么一两个。

所以,如果我们为每个对象设置一个“年龄” —— 这个“年龄”实际上指的是该对象所经历过的 GC 次数。

这样一来,我们就可以区分出对年轻的对象和老的对象,并为他们分别采用不同的 GC 策略。

年轻代
由于存活对象少,可以采用 Copying GC 的策略来清理。

老年代
由于一直活着,反复复制开销较大,可以采用 Mark-sweep 的策略清理。

2.3 小结

这一小节介绍的 GC 策略其实在很多其他编程语言 runtime 中也广泛使用。
以 Java 为例,
Java 使用的垃圾标记方法就是追踪垃圾回收算法,但它把引用分为了四种:强引用、软引用、弱引用、虚引用。
在 Java 中,垃圾回收的方法也是采用分代假说,但 Java 以前还有一个用来存放静态数据的持久代, 1.8 的时候移除了。

其实各种方法都有自身的局限性,因此我们要结合对象的生存周期来选择合适的处理策略。
现如今一些 runtime 采用了比较不同的 GC 策略,有很多也是根据上面所说的这些 GC 策略衍生而来的,说白了就是修复他们 bug 的方案。

如果按照这个思路去理解其他的 GC 策略的话,就可以很快地去理解啦!

3 Go 中的内存管理

Go 在堆上为对象分配内存,它内存管理的手段是提前将内存分块

具体的步骤如下:

  1. Go 会通过系统调用 mmap() 申请一大块内存,例如 4 MB
  2. 将内存划分成大块,例如 8 KB,称作 mspan
  3. 将大块继续划分成特定大小的小块,用于对象分配

mspan 分为两种:

  • noscan mspan: 分配不包含指针的对象 —— GC 不需要扫描
  • scan mspan: 分配包含指针的对象 —— GC 需要扫描

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

3.1 内存分配 —— GMP 模型

TCMalloc 内存分配器的方法理念:
从共享内存中获取内存要加锁。如果给线程添加内存缓存,从而避免加锁,就可以减少竞争从而提高性能。

TCMalloc 将内存分为三个部分:

  • ThreadCache 线程缓存
  • CentralFreeList 中央缓存
  • PageHeap 堆内存

分配内存时,自上而下地寻找可用空间。

Go 的内存分配就是基于 TCMalloc 的理念,分为了下面三个部分:

  • mcache 线程缓存
  • mcentral中央缓存
  • mheap堆内存

唯一不同的是, mcache 的拥有者并不是系统线程,而是逻辑处理器。

下图展示了内存分配的过程,其中 g 表示协程, m 表示系统线程, p 表示逻辑处理器。

image.png

3.2 Go 内存管理的优化

线上程序内存分配的特点:

  1. 对象分配是非常高频的
  2. 小对象的占比比较高

Go 内存分配的问题:

  1. 分配链路很长
  2. 对象分配函数 mallocgc() 占用时间较高

Balanced GC

为每个 g 绑定一块大内存(1 KB), 称为 goroutine allocation buffer (GAB) (其实就是一个栈),用于 noscan 类型的小对象分配。

分配方法:

if top + size <= end {
    addr := top
    top += size
    return addr
}

对于 Go 而言,一个 GAB 其实是一个 大对象

GAB 的目的是将多个小对象的分配合并成一次大对象的分配。

当 GAB 数量超过一定阈值的时候,需要清理一些 GAB 以防止内存泄漏。
GAB 的清理是采用 Copying GC 的算法,将一些 GAB 里残余的小对象合并到一个幸存者 GAB 中。

本质上, Balanced GC 是将多次小对象的分配合并成一次大对象的分配。

Balanced GC 只负责 noscan 对象的分配和移动,对象的标记和回收依然依赖 Go GC 本身,并和 Go GC 保持兼容。

4 编译器和静态分析

4.1 静态分析

不执行程序代码,推导程序行为。

控制流:程序执行的流程 数据流:数据在控制流上的传递

过程内分析:仅在函数内部分析 过程间分析:考虑函数调用时参数传递和返回值的数据流和控制流(有点复杂,需要联合求解,如多态)

4.2 编译器优化

Tradeoff: 用编译时间换取高效的机器码

4.2.1 函数内联

优点:

  • 消除函数调用开销
  • 将过程间分析转化为过程内分析

缺点:

  • 函数体变大, icache 不友好
  • 生成的 Go 镜像变大

所以需要一定的内联策略,如函数规模等。

可以使用 micro-benchmark 快速验证性能优化。

4.2.2 逃逸分析

分析代码中指针的作用域,若指针 p 有传递给其他参数,或者传递给全局变量等情况,导致 p 会出现在其他作用域中,则表示 p 逃逸出 s。

未逃逸的对象可以在栈上分配,而不需要丢进堆中,可以降低 GC 的负担。