关于GO的垃圾回收机制小记

1,829 阅读5分钟

概述

回收线程(collector) 和 其他线程(mutator)可并行执行,允许多个回收线程并行。

  • 类型精确 (type accurate)
  • 非代龄 (non-generational)
  • 非收缩 (non-compacting)
  • 写屏障 (write barrier)
  • 并发标记 (mark) 和 清理 (sweep)

 

回收周期

回收周期包含以下几个步骤:

 

1. 清理结束

  • STOP-THE-WORLD, 让所有P到达安全点。
  • 清理所有未及时清理的内存块。(清理未结束前执行runtime.GC)

 

2. 开始标记

  • 状态 GCoff -> GCmark。

    • 启动写屏障。
    • 启动辅助(mutator assissts)。
    • 根标记任务排队。
    • 所有P写屏障启用前,不扫描任何对象。
  • START-THE-WORLD。

    • 调度器(schedule)启动标记工人(mark workers) 和分配辅助(assists)。
    • 写屏障遮蔽指针写入(shades both the overwritten pointer and the new pointer value)。
    • 新分配对象(malloc)直接标记为黑色。
  • 执行根标记任务。

    • 扫描所有栈(stack)。(会导致goroutine停止,扫描完成后恢复)
    • 遮蔽所有全局变量(global)。
    • 遮蔽堆外运行时(off-heap runtime)数据结构中的堆指针。
  • 清空灰色队列。(drains the work queue of grey objects)

    • 扫描其中灰色对象(grey object),将其标记为黑色。
    • 对其包含的指针进行着色,将引用目标放入灰色队列。
  • 终止算法(distributed termination algorithm)检测何时不再有根标注任务和灰色对象。

    • 标记结束

 

3. 标记结束

  • STOP-THE-WORLD。
  • 状态GCmarktermination,禁用标记工人和分配辅助。
  • 处理内部任务。

 

4. 执行清理

  • 状态GCoff,设置清理状态,禁用写屏障

  • START-THE-WORLD

    • 从此刻起,新分配对象为白色,必要时会在分配前清理目标对象。
  • 后台执行并发清理(oncurrent sweeping),并响应分配操作。

 

5. 重新执行

  • 分配超过阈值,重新回到第1步

 

并发清理

清理与用户逻辑并发执行,专门的goroutine在后台挨个清理。标记结束后,所有span都被标记为需要清理。

为避免向OS申请过多内存,首先尝试清理现有span以获取可复用空间。确保不会在未清理的span上执行操作,避免破坏标记位图。回收期间,mcache所持有的span全部收回mcentral。重新获取时,会执行清理操作。而当下一回收周期启动时,也会先完成未清理任务。

 

回收速率

环境变量GOGC控制了回收和分配间的线性比例。如果GOGC=100,使用了4MB,那么到达8MB时,垃圾回收将被再次启动。

 

控制器

控制器(gcController)用于GC调控,决定何时触发,有多少工作量,需要多少投入和辅助。基于每个回收周期的堆增长和CPU利用率等数据,以反馈算法(feedback control algorithm)进行调整。该算法将辅助标记和后台标记的CPU利用率优化为GOMAXPROCS的25%。

垃圾回收有个大问题需要解决,那就是什么时候启动?过早或频繁启动,除浪费cpu资源外,还会加大用户逻辑停顿时间。过晚又会导致堆膨胀,浪费更多内存。如何在两者间平衡,是个巨大的挑战。

 

并发回收阶段,对象分配速度可能远快于回收标记。这会引发一系列恶果,比如堆恶性扩张,或导致单个回收周期无法结束,以至于垃圾回收机制瘫痪。此时,暂停用户逻辑,让该线程临时参与辅助回收就十分必要。此举非但能抑制用户逻辑在短时间内大批量分配内存,还可提升回收效率。平衡分配和回收,更充分复用内存。

 

写屏障

直观上看,写屏障是编译器在用户逻辑内插入的额外指令。

因三色标记和用户逻辑并发执行,那么已检查的黑色对象就可能被修改。假设已扫描黑色对象内部指针“突然”指向一个尚未扫描的白色对象。按三色标记流程,按三色标记流程,黑色不会再次扫描,如此就导致该白色对象最终被回收,从而引发逻辑错误。

A(黑)引用B(灰); B引用C(白)。\

然后,A引用C,B不再引用C。\

如没有写屏障,那么A不会再次扫描,C保持白色被回收。

 

写屏障启用后,对指针的修改会跳转到写屏障指令,以便对其重新标记、扫描。如此,其引用的白色对象会存活下来。写屏障解决了垃圾回收与用户逻辑并发执行的冲突,有助于减少重新扫描次数,简化和消除了某些复杂的机制。

 

写屏障仅在垃圾回收时启动,通过特定开关进行判断。
正常情况下,用户逻辑不会跳转到这些额外的指令,性能不受影响。

 

三色标记

扫描内存时,使用三种颜色标记对象状态。

  • 起初,所有对象默认为白色

  • 扫描,可达对象如包括指针,标记为灰色后放入待处理队列,否则直接黑色。

  • 依次从队列提取灰色对象,扫描其指针字段。

    • 该灰色对象标记为黑色,表示存活。
    • 该字段所引用对象,标记为灰色后放回队列。
  • 扫描和队列结束,仅剩黑白两色

    • 黑色为存活对象。
    • 白色表示待回收对象。

通过队列实现递归扫描,找出存活(黑色)对象,其余被回收。这就是三色标记原理,大体与扫描流程相对应。至于用户逻辑在扫描阶段新分配的用户对象,则直接标记为黑色。

标记操作对于:\

a := make([]*int, 1e9)\

b := make([]int, 1e9)

\

性能差别巨大。因为b无需深入内部扫描,故而快的多。
所以,对于超大内存使用要慎重。
比如说,分配一大块[]byte或mmap,持有阻止回收。然后,在内部使用uintptr二次分配。

本文正在参加技术专题18期-聊聊Go语言框架