2

Go GC 垃圾回收

 2 years ago
source link: https://wnanbei.github.io/post/go-gc-%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

垃圾回收 - GC - garbage collection 是自动内存管理的一种形式。通常由垃圾收集器收集并适时回收或重用不再被对象占用的内存。

垃圾回收作为内存管理的一部分,包含 3 个重要的功能:

  • 如何分配和管理新对象。
  • 如何识别正在使用中的对象。
  • 如何清除不再使用的对象。

为什么需要垃圾回收

减少错误和复杂性

  • 提供保证,不再被引用的对象将最终被收集。
  • 避免悬空指针、多次释放等手动管理内存时出现的问题。
  • 屏蔽了内存管理的复杂性,开发者可以更好的关注核心的业务逻辑。
  • 避免了两个模块同时维护了同一内存时,释放内存将会变得困难的问题。业务模块将真正的实现解耦,从而有利于开发、调试并开发出更大规模、高并发项目。

垃圾回收有额外的成本:

  • 需要保存内存的状态信息(例如是否使用,是否包含指针)并扫描内存。
  • 在很多时候,还需要中断整个程序来处理垃圾回收。

因此,在要求极致的速度和内存要求极小的场景(例如嵌入式、系统级程序)时并不适用。但是却是开发大规模、分布式、微服务应用程序的极佳选择。

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

  1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
  2. 执行栈:每个 Goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
  3. 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

STW 是 Stop the worldStart the world 的缩写。指停止赋值器进一步操作对象图,从 stop 到 start 这两个动作之间的时间间隔,即万物静止。用于保证实现的正确性、防止无止境的内存增长等问题。

此过程中整个用户代码被停止或者放缓执行,STW 越长,对用户代码造成的影响(例如延迟)就越大,对时间敏感的实时通信等应用程序会造成巨大的影响。

GC 在需要进入 STW 时,需要通知并让所有的用户态代码停止,但是 for {} 所在的 Goroutine 永远都不会被中断,从而始终无法进入 STW 阶段。

在自 Go 1.14 之后,这类 Goroutine 能够被异步地抢占,从而使得进入 STW 的时间不会超过抢占信号触发的周期,程序也不会因为仅仅等待一个 Goroutine 的停止而停顿在进入 STW 之前的操作上。

永远不可能有最好的垃圾回收算法,因为每一个应用程序所在的硬件条件、工作负载、性能要求都是不同的。

每一种语言侧重的垃圾回收目标会不尽相同。垃圾回收的常见指标包括了程序暂停时间、空间开销、回收的及时性等,根据侧重于不同的设计目标会产生不同的垃圾回收策略。

标记-清扫

标记-清扫(Mark-sweep)策略顾名思义分为 2 个主要的阶段:

  1. 第一阶段是扫描并标记当前活着的对象。

  2. 第二阶段是清扫没有被标记的垃圾对象。

因此,标记-清扫算法是一种间接的垃圾回收算法,其不是直接查找垃圾对象,而是通过活着的对象倒推出垃圾对象。

扫描的过程一般是从栈上的根对象开始, 只要对象引用了其他的堆对象,就会一直往下扫描。因此搜索方式可以采取深度优先搜索或者广度优先搜索的方式。

标记-清扫算法主要的缺点在于:

  • 可能会产生内存碎片或空洞。这会导致由于没有连续的内存而使新对象分配失败。
  • 一般需要在标记阶段,STW 暂停所有的程序运行。否则可能会破坏标记的结果。

标记-压缩

Mark-compact

半空间复制

Semispace copy

reference counting

分代 GC 指的是将按照对象存活时间进行划分。

这种策略的重要前提是:死去的一般都是新创建不久的对象。因此,没有必要反复的扫描旧对象。

这大概率会加快垃圾回收的速度,提高处理能力和吞吐量,减少程序暂停的时间。

但是分代 GC 有成本的:

  • 这种策略没有办法及时回收老一代的对象。
  • 需要额外开销引用和区分新老对象,特别是有多代的时候。

三色标记法

Go 语言采用并发三色标记算法来进行垃圾回收。

三色标记法是对标记-清扫法在标记阶段的改进。

三色标记本身是最简单的一种垃圾回收策略,实现很简单。引用计数由于固有的缺陷,在并发时不可扩展的特性很少被使用,不适合 Go 这样高并发的语言。

状态

从垃圾回收器的视角来看,三色标记法规定了三种不同类型的对象,用不同的颜色相称:

  • 白色 - 可能死亡

    在回收开始前,所有对象均为白色。当标记结束后,白色对象将被回收。

  • 灰色 - 波面

    已被回收器标记,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向未被标记的白色对象。

  • 黑色 - 确定存活

    已被回收器标记,其中所有字段都已被标记,黑色对象中任何一个指针都不可能直接指向白色对象。此对象将不会被回收。

算法流程

  1. 从 Root 对象出发扫描所有根对象,将他们引用的对象标记为灰色。
  2. 分析灰色对象是否引用了其他对象,如果没有引用其它对象则将该灰色对象标记为黑色,如果有引用则将它变为黑色的同时将它引用的对象也变为灰色。
  3. 重复步骤3,直到灰色对象队列为空,标记过程完成,等待回收白色对象。
  4. 将所有黑色对象变为白色,等待下一轮 GC。

优点:

  • 最大的好处是可以异步执行标记,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC
  • 三色标记法掌握了更多当前内存的信息,因此可以更加精确地按需调度,而不用像标记清扫法那样只能定时执行

缺点:

  • 异步执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative 假阴性的算法。

并发三色标记法的问题

垃圾回收器的正确性体现在:不应出现对象的丢失,也不应错误的回收还不需要回收的对象。

当以下两种情况同时发生时,会破坏并发垃圾回收器的正确性:

  1. 赋值器使一个黑色对象引用了白色对象。

  2. 赋值器断开了灰色对象与白色对象间未经垃圾回收器访问过的关系。

只要能够避免其中任何一个条件,都不会出现对象丢失的情况,因为:

  1. 如果 1 被避免,则所有白色对象均被灰色对象引用,没有白色对象会被遗漏。

  2. 如果 2 被避免,即便白色对象的指针被写入到黑色对象中,但从灰色对象出发,总存在一条没有访问过的路径,从而找到到达白色对象的路径,白色对象最终不会被遗漏。

写屏障、混合写屏障

写屏障是一个在并发垃圾回收器中才会出现的概念。

强三色不变式

弱三色不变式

  1. Mark Setup 标记准备阶段,STW 并打开 Write Barrier
  2. Mark Termination 标记结束,STW
  3. Sweeping 开始清理,并发执行

Go GC 总结

  • Go 1:串行三色标记清扫
  • Go 1.3:并行清扫,标记过程需要 STW,停顿时间在约几百毫秒
  • Go 1.5:并发标记清扫,停顿时间在一百毫秒以内
  • Go 1.6:使用 bitmap 来记录回收内存的位置,大幅优化垃圾回收器自身消耗的内存,停顿时间在十毫秒以内
  • Go 1.7:停顿时间控制在两毫秒以内
  • Go 1.8:混合写屏障,停顿时间在半个毫秒左右
  • Go 1.9:彻底移除了栈的重扫描过程
  • Go 1.12:整合了两个阶段的 Mark Termination,但引入了一个严重的 GC Bug 至今未修(见问题 20),尚无该 Bug 对 GC 性能影响的报告
  • Go 1.13:着手解决向操作系统归还内存的,提出了新的 Scavenger
  • Go 1.14:替代了仅存活了一个版本的 scavenger,全新的页分配器,优化分配内存过程的速率与现有的扩展性问题,并引入了异步抢占,解决了由于密集循环导致的 STW 时间过长的问题

在具有 GC 的语言中,内存泄漏用严谨的话来说应该是:

预期能很快被释放的内存由于附着在了长期存活的内存上、或生命期意外地被延长,导致预计能够立即回收的内存长时间得不到回收。

Go 中内存泄漏的几种情况:

  1. 被根对象引用

    当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放。

  2. Goroutine 泄漏

    Goroutine 作为一种逻辑上理解的轻量级线程,在运行过程中需要消耗一定的内存来保存用户代码的上下文信息。

    因此,如果一个程序持续不断地产生新的 Goroutine、且不结束已经创建的 Goroutine 并复用这部分内存,就会造成内存泄漏的现象。

  3. Channel 泄漏

    Channel 作为一种同步原语,会连接两个不同的 Goroutine,如果一个 Goroutine 尝试向一个没有接收方的无缓冲 Channel 发送消息,则该 Goroutine 会被永久的休眠,整个 Goroutine 及其执行栈都得不到释放。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK