Go 内存管理 | 青训营笔记

65 阅读6分钟

Go 内存管理 | 青训营笔记

这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天,主要记录相关的知识点。

本堂课重点内容

  • 自动内存管理
  • Go内存管理
  • Go编译器优化

性能优化

实际的性能优化,整体上可以从如下五个方向来考虑:

  • 业务代码
  • SDKSDK
  • 基础库
  • 语言运行时
  • OSOS

对于实际的业务优化,我们可以针对业务场景,具体尝尽具体分析。

而语言运行时的优化,则需要考虑更多的场景,解决更通用的性能问题。

应该以 真实可靠 的数据来驱动优化代码,而不是猜测,可以使用辅助工具 pprofpprof ,优先解决最大瓶颈。

自动内存管理

程序在运行时常常会根据需求动态分配内存,比如 maloc/newmaloc/new。而这些动态分配的内存如果不能够被很好的释放,那么就可能会导致严重的内存泄漏的问题。

而自动内存管理(垃圾回收,又称 GCGC)这一机制,就是一种通过程序语言的运行时系统来自动管理动态内存的机制,避免了程序员手动管理内存,使程序员只要关注业务逻辑,保证了程序的 安全性和正确性

GCGC 的主要任务有三个:

  • 为新对象分配内存空间
  • 找到存活对象
  • 回收死亡对象的内存空间

GCGC 的角度看,可以将 GoGo 中的线程分为两类:

  • MutatorMutator ,业务线程,分配新对象,修改对象的指向关系
  • CollectorCollectorGCGC 线程,找到存活对象,释放死亡对象的内存空间。

image.png

GoGo 对于这两种线程的管理有三种算法:

  • SerialSerial GCGC , 这种只有一个 CollectorCollector ,当 ControllorControllor 工作时,会暂停 MutatorMutator 的工作
  • ParallelParallel GCGC ,可以支持多个 CollectorsCollectors 进行 GCGCControllorsControllors 工作时,会暂停 MutatorMutator 的工作
  • ConcurrentConcurrent GCGC , 可以支持多个 CollectorsCollectors 进行 GCGC , 可以支持 MutatorsMutatorsCollectorsCollectors 同时工作

三种算法的对比图:

image.png

image.png

image.png

对于 ConcurrentConcurrent GCGC 来说,其必须能够监控到对象的变化,也即:

image.png

对于一个 GCGC , 我们可以从四个角度来评价 :

  • 安全性 SafetySafety , 不能回收存活对象,这是最基本的要求
  • 吞吐量 ThroughputThroughput , 程序运行 GCGC 所需要的时间,1GC时间程序总运行时间1 - \frac{GC时间}{程序总运行时间}
  • 暂停时间 PausePause TimeTime , 能否被业务所感知
  • 内存开销 SpaceSpace OverheadOverheadGCGC 自身的元数据开销

下面是 GCGC 最常用的两种技术

追踪垃圾回收(Tracing garbage collection)

这种技术会对指向了关系不可达的对象的指针或内存进行清理,一般可以分为三个步骤。

  1. 标记根对象,比如静态变量、全局变量、常量、线程栈等
  2. 标记,找到所有可达对象,即求出所有指针可达的传递闭包
  3. 清理,回收所有不可达对象的内存空间

对于清理,常用的策略有三种:

  • Copying GC: 将存活对象从一块内存空间复制到另外一块内存空间,原先的空间可以直接进行对象分配

  • image.png

  • Mark-sweep GC: 将死亡对象所在内存块标记为可分配,使用 free list 管理可分配的空间

  • image.png

  • Mark-compact GC: 将存活对象复制到同一块内存区域的开头

  • image.png

虽然后很多种策略,但是实际运行时环境是复杂的,需要灵活根据对象的不同生命周期使用不同的标记请清理策略,一个经典的例子是 分代GCGenerational分代GC,Generational GCGC , 其根据对象经历 GCGC 的次数来给对象划分年龄,对于年轻的对象多使用 CopyingCopying GCGC , 而对于老年对象多实用 MarksweepMark-sweep GCGC

引用计数(Reference counting)

引用计数方法就是对于每个对象都维护一个高度关联的引用数目,当引用计数为 00 时,进行内存释放。这也是 C++11C++11 中新增的 shared_ptrshared\_ptr 所推荐的管理内存的方式。

优点:

  • 这种方式使得程序员可以更多关注业务代码,而不需要了解实际运行时的更多细节
  • 内存管理的操作被分摊在了程序实际运行中

缺点:

  • 引用计数的维护是原子操作,开销较大
  • 无法处理环形的结构
  • 需要维护引用计数,带来额外的内存开销
  • 回收内存时仍可能触发暂停

Go内存管理及其优化

Go内存分配

GoGo 的内存分配,是通过程序运行时的预分配实现的。

GoGo 会在程序运行前,提前将内存分块,具体步骤如下:

  • 调用系统调用 mmap()mmap()OSOS 申请一大块内存,例如 4MB4 MB
  • 先将内存划分成大块,例如 8KB8 KB ,称作 mspanmspan
  • 再将大块继续划分成特定大小的小块,用于对象分配
  • noscannoscan mspanmspan: 分配不包含指针的对象 —— GCGC 不需要扫描
  • scanscan mspanmspan: 分配包含指针的对象 —— GCGC 需要扫描

image.png

实际对象分配的过程,就是在这一堆小块里面查找一个大小合适的块并返回。

GoGo 对这些内存会做多级缓存机制,对于从 OSOS 分配得的内存,其被内存管理回收后,立刻归还给 OSOS,而是在 GoruntimeGo runtime 内部先缓存起来,从而避免频繁向 OSOS 申请内存。内存分配的路线图如下。

image.png

Go编译器优化

GoGo 的编译器很多时候,会为了编译的效率,而放弃很多的优化,比如:函数内联等。

函数内联

函数内联会使得被调用的函数的副本复制到调用方,从而减少函数的调用次数,避免了函数调用、参数传递、栈的开销,还可以帮助分析其他的优化,比如逃逸分析。

但是函数内联也可能导致函数体变得很大,导致最终生成的 GoGo 镜像过大的问题。

逃逸分析

逃逸分析的基本定义为:代码中指针的动态作用域,即指针在何处可以被访问。

分析逃逸分析的基本思路:

  • 从对象分配处出发,沿着控制流,观察数据流。若发现指针 pp 在当前作用域:

    • 作为参数传递给其他函数
    • 传递给全局变量
    • 传递给其他的 goroutinegoroutine
    • 传递给已逃逸的指针指向的对象
  • 则指针 pp 逃逸,反之则没有逃逸.

优化:未逃逸出当前函数的指针指向的对象可以在栈上分配

  • 对象在栈上分配和回收很快:移动 spsp 即可完成内存的分配和回收;
  • 减少在堆上分配对象,降低 GCGC 负担。

个人总结

本次课程主要学习了:

  • 自动内存管理
  • Go内存管理
  • Go编译器优化