Go 自动内存管理 | 青训营笔记

104 阅读5分钟

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

一、本堂课重点内容

  • 常见 GC 算法
    • Tracing garbage collection
    • Generational GC
    • Reference counting
  • Go 的分配策略
  • 编译器优化

二、详细知识点介绍:

自动分配内存

  • 保证安全性和正确性
    • 避免 double-free
    • 避免使用释放后的内存

实现的功能:

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

相关概念

  • Mutator:业务线程,分配新对象,修改对象指向关系
  • Collector:GC 线程,找到存活对象,回收死亡对象的内存空间
  • Serial GC:只有一个 collector
  • Parallel GC:支持多个 collectors 同时回收的GC 算法
  • Concurrent GC: mutator(s) 和 collector(s)可以同时执行

image.png

GC 的挑战:

  • 被标记完的对象又引用了新对象, 这种变化难以感知(三色标记写屏障)

追踪垃圾回收

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

  • 标记根对象

    • 静态变量、全局变量、常量、线程栈等
  • 标记:找到可达对象

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

    image.png

  • 清理:所有不可达对象

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

      image.png

    • 将死亡对象的内存标记为"可分配” (Mark-sweep GC)

      image.png

    • 移动并整理存活对象 (Mark-compact GC)

      image.png

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

分代 GC

  • 分代假说 (Generational hypothesis): most objects die young
  • Intuition:很多对象在分配出来后很快就不再使用了
  • 每个对象都有年龄:经历过 GC 的次数
  • 目的:对年轻和老年的对象,制定不同的 GC 策略,降低整体内存管理的开销
  • 不同年龄的对象处于 heap 的不同区域

为不同对象的生命周期使用不同的策略:

  • 年轻代 (Young generation)
    • 常规的对象分配
    • 由于存活对象很少,可以采用 copying collection
    • GC吞叶率很高
  • 老年代 (Old generation)
    • 对象趋向于一直活著,反复复制开销较大
    • 可以采用 mark-sweep collection

引用计数

  • 每个对象都有一个与之关联的引用数目

  • 对象存活的条件:当且仅当子 1 用数大于。

  • 优点

    • 内存管理的操作被平摊到程序执行过程中
    • 内存管理不需要了解 runtime 的实现细节:C++ 智能指针 (smart pointer)
  • 缺点

    • 维护引用计数的开销较大:通过原子操作保证对引用计数操作的原子性和可见性
    • 无法回收环形数据结构 (weak reference)
    • 内存开销:每个对象都引入的额外内存空间存储引用数目
    • 回收内存时依然可能引发暂停(回收大型数据结构)

image.png

Go 内存分配

分块

目标:为对象在 heap 上分配内存

策略: 提前将内存分块, 根据对象的大小, 选择最合适的块返回

  • 调用系统调用 mmap() 向 OS 申请一大块内存,例如 4 MB
  • 先将内存划分成大块,例如 8KB,称作 mspan
  • 再将大块继续划分成特定大小的小块,用于对象分配
    • noscan mspan: 分配不包含指针的对象 - GC 不需要扫描
    • scan mspan: 分配包含指针的对象 — GC 需要扫描

下图的三个都是大块, 根据需要分成不同的小块

image.png

缓存

借鉴了 TCMalloc: thread caching

  • G - Goroutine,协程,是参与调度与执行的最小单位
  • M - Machine,系统级线程
  • P - Processor,逻辑处理器,关联了 Goroutine 队列。

image.png

  • 每个 p 包含一个 mcache 用于快速分配,用于为绑定于 p 上的 g 分配对象
  • mcache 管理一组 mspan
  • 当 mcache 中的 mspan 分配完毕,向 mcentral 申请带有末分配块的 mspan
  • 当 mspan 中没有分配的对象,mspan 会被缓存在 mcentral 中,而不是立刻释放井归还给 OS

编译器优化

逃逸分析

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

大致思路, 从对象分配处出发,沿着控制流,观察对象的数据流

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

  • 作为参数传递给其他函数
  • 传递给全局变量
  • 传递给其他的 goroutine
  • 传递给已逃逸的指针指向的对象

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

三、实践练习例子:

Balance GC

  • 对象分配是非常高频的操作:每秒分配 GB 级别的内存
  • 小对象占比较高
  • Go 内存分配比较耗时
    • 分配路径长:g -> m -> p -> mcache -> mspan -> memory block -> return pointer
    • pprof:通过 pprof 也能发现, 对象分配的函数是最频繁调用的函数之一

Balance GC 的优化方案:

  • 每个 g 都鄉绑定一大块内存(1KB),称作 goroutine allocation buffer (GAB)
  • GAB 用于 noscan 类型的小对象分配:< 128 B
  • 使用三个指针维护 GAB: base, end, top
  • Bump pointer(指针碰撞)风格对象分配
    • 无须和其他分配请求互斥
    • 分配动作简单高效

类似于每个 Goroutine 单独的栈

image.png

if top + size <= end {
    addr := top
    top += size
    return addr
}
  • GAB 对于 Go 内存管理来说是一个大对象

    image.png

  • 本质:将多个小对象的分配合井成一次大对象的分配

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

    image.png

  • 方案:移动 GAB 中存活的对象

    • 当 GAB 总大小超过一定國值时,将 GAB 中存活的对象复制到另外分配的 GAB 中
    • 原先的 GAB 可以释放,避免内存泄漏
    • 本质:用copying GC 的算法管理小对象

    image.png

四、课后个人总结:

  • 优化一定要以数据驱动
  • GMP 模型, 内存分配 还要找其他资料

五、引用参考: