Go runtime之 GC

Mark & Sweep

Garbage Collection

现代高级编程语言管理内存的方式分为两种:自动和手动。

CC++ 等编程语言使用手动管理内存的方式,工程师编写代码过程中需要主动申请或者释放内存;优势是性能高,例如 nginxredis,缺点是门槛高,内存管理容易出 BUG

PHPJavaGo 等语言使用自动的内存管理系统,有内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集就是我们常说的 GC

image-20231221102236146

主流的垃圾回收算法:

  • 引用计数
  • 追踪式垃圾回收(Go 使用其中一种,Java 也使用其中一种。)

Go 现在用的三色标记法就属于追踪式垃圾回收算法的一种。

Mark & Sweep

image-20231221162218730

  • STW

    Stop the world,GC 的一些阶段需要停止所有的 mutator 以确定当前的引用关系(例如上图,只有程序暂停的时候,才能确定当前的指向关系)。这便是很多人对 GC 担心的来源,这也是 GC 算法优化的重点。(减少 STW 的时间)

  • Root

    根对象是 mutator 不需要通过其他对象就可以直接访问到的对象。比如全局对象,栈对象中的数据等。通过 Root 对象,可以追踪到其他存活的对象。(例如 main.main 引用一些对象,或者全局会使用的对象)

Mark Sweep 两个阶段:标记(Mark)和 清除 (Sweep)两个阶段,所以也叫做 Mark-Sweep 垃圾回收算法。

实现过程

Animation_of_the_Naive_Mark_and_Sweep_Garbage_Collector_Algorithm

这个算法就是严格按照追踪式算法的思路来实现的:

  • Stop the World(Go v1.1,这个阶段可能是秒级)
  • Mark:通过 Root 和 Root 直接间接访问到的对象,来寻找所有可达的对象,并进行标记。
  • Sweep:对堆对象迭代,已标记的对象置位标记。所有未标记的对象加入 freelist,可用于再分配。
  • Start the World

这个算法最大的问题是 GC 执行期间需要把整个程序完全暂停,朴素的 Mark Sweep 是整体 STW,并且分配速度慢,内存碎片率高。

也就是说,如果存在一条从根出发的指针链最终可指向某个对象,就认为这个对象是存活的。这样,未能证明存活的对象可以标记为死亡。

并发

标记过程需要 STW,因为对象引用关系如果在标记阶段做了修改,会影响标记结果的正确性。

并发 GC 分为两层含义:

  • 每个 marksweep 本身是多个线程(协程)执行的(也就是 concurrent);STW 阶段同时有多个 goroutine 执行 marksweep
  • mutatorcollector 同时运行(也就是 background);应用程序和垃圾回收器同时运行,不用垃圾回收时无法执行应用程序。

image-20231221164141141

如果在标记阶段没有 STW,有可能出现栈上的对象没有指向 E,但是程序在运行过程中被赋值,此时如果回收 E,则会出现问题。

Go v1.3,标记过程 STW,并发 Sweep

concurrent 这一层比较好实现,GC 时整体进行 STW,那么对象应用关系不会再改变,对 mark 或者 sweep 任务进行分块,就能多个线程(协程)concurrent 执行任务 marksweep

background

对于 background 这一层,也就是说 mutatormarksweep 同时运行,则相对复杂。

  • 1.3 以前的版本使用标记 - 清扫的方式,整个过程都需要 STW
  • 1.3 版本分离了标记和清扫的操作,标记过程 STW,清扫过程并发执行

background sweep 是比较容易实现的,因为 mark 后,哪些对象是存活,哪些是要被 sweep 是已知的,sweep 的是不再引用的对象。

sweep 结束前,这些对象不会再被分配到,所以 sweepmutator 运行共存。无论全局还是栈不可能能访问的到这些对象,可以安全清理。

1.5 版本在标记过程中使用三色标记法。标记和清扫都并发执行的,但标记阶段的前后需要 STW 一定时间来做 GC 的准备工作和栈的 re-scan

Tri-color Mark & Sweep

Go v1.5,并行 Mark & Sweep

image72

三色标记是对标记清除法的改进,标记清除法在整个执行时要求长时间 STW,Go 从 1.5 版本开始改为三色标记法。

  1. 初始将所有内存标记为白色
  2. 然后将 roots(全局变量和栈上的局部变量指向的变量) 加入待扫描队列(进入队列即被视为变成灰色)
  3. 然后使用并发 goroutine 扫描队列中的指针
  4. 如果指针还引用了其他指针,那么被引用的也进入队列,被扫描的对象视为黑色

image-20231221174849569

对象和颜色区分:

  • 白色对象:潜在的垃圾,其内存可能会被垃圾收集器回收
  • 黑色对象:活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象,垃圾回收器不会扫描这些对象的子对象
  • 灰色对象:活跃的对象,因为存在指向白色对象的外部指针,垃圾回收器会扫描这些对象的子对象

Tri-color Marking

垃圾收集器从 root 开始然后跟随指针递归整个内存空间。分配于 noscanspan 对象,不会进行扫描(因为 noscan 的对象中不包含指针)。

image-20231221172853206

然而,此过程不是由同一个 goroutine 完成的,每个指针都排队在工作池中,然后,先看到的被标记为工作协程的后台协程从该池中出队,扫描对象,然后将在其中找到的指针排入队列。

image-20231221172921726

Tri-color Coloring

染色流程:

  • 一开始所有对象被认为是白色

    image-20231221173211660

    s1s2 都标记为白色。因为最右边的实例 struct2 在匿名函数中创建的,因此无法从堆栈中访问,因此它保持白色并且可以清除。

  • 根节点(stacks, heap, global variables)被染色为灰色,noscan 对象标记为黑色

    image-20231221173228422

    将栈上引用的对象 s1 标记为灰色,放入到队列中,将 noscanspan 对象 s2 标记为黑色

一旦主流程走完,gc 会:

  • 选一个灰色对象,标记为黑色

    image-20231221173659501

    将对象 s1 标记为黑色

  • 便利这个对象的所有指针,标记所有其引用的对象为灰色

    image-20231221173723290

    便利 s1所有的指针,找到 s2,将 s2 标记为灰色,放入队列

最终直到所有对象需要被染色。

标记结束后,黑色对象是内存中正在使用的对象,而白色对象是要收集的对象。

image-20231221174042797

由于 struct2 的实例是在匿名函数中创建的,并且无法从堆栈访问,因此它保持为白色,可以清楚。

颜色在内部实现原理:

每个 span 中有一个名为 gcmarkBits 的位图属性,该属性跟踪扫描,并将相应的位设置为1.

image-20231221174744989

可以看到 1.5 版本和之前的版本相比,在 GC 过程中 Pauses 的时间对比。到 1.5 版本已经优化的非常好了。

Write Barrier

1.5 版本在标记过程中使用三色标记法。回收过程主要有四个阶段,其中,标记和清扫都并发执行的。但标记阶段的前后需要 STW 一定时间来做 GC 的准备工作和栈的 re-scan

使用并发的垃圾回收,也就是多个 MutatorMark 并发执行,想要在并发或者增量的标记算法中保证正确性,我们需要达成以下两种三色不变性(Tri-color invariant)中的任意一种:

  • 强三色不变性:黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象(也就是黑色对象不可能指向白色对象,因为黑色对象是可达,指向白色对象意味着白色对象也可达,而可达对象不可能是白色)
  • 弱三色不变性:黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径

弱三色不变性

如果在扫描过程中,如果没有 STW ,可能会发生的情况:标记过程中有对象的引用关系被改变,导致被应用的对象最终处于白色而被回收。

image-20231221175235970

  • 这个图中,对象 B 中包含一个指针字段 e 指向对象C,对象 B 为灰色对象,处于待扫描状态
  • 同时,对象 A 中也包含一个指针 f 指向对象 C,对象 A 已经完成扫描,是黑色对象
  • 如果在标记过程中,没有 STW,可能发生应用对象在标记阶段做了修改。例如 对象 B 的指针 e 被删除
  • 此时扫描对象 B 之后,对象 C 就只能处于白色,会被直接回收

可以看出,一个白色对象被黑色对象引用,是注定无法通过这个黑色对象来保证自身存活的;与此同时,如果所有能到达它的灰色对象与它之间的可达关系全部遭到破坏,那么这个白色对象必然会被视为垃圾清除掉。

故当上述两个条件同时满足时,就会出现对象丢失的问题。

如果这个白色对象下游还引用了其他对象,并且这条路径是指向下游对象的唯一路径,那么他们也是必死无疑的。

为了防止这种现象发生,最简单的方式就是 STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是 STW 的过程有明显的资源浪费,对所有的用户程序都有很大影响,如何能在保证对象不丢失的情况下合理的尽可能的提高 GC 效率,减少 STW 时间呢?

Dijkstra 写屏障

插入屏障拦截将白色指针插入黑色对象的操作,标记其对应对象为灰色状态,这样就不存在黑色对象应用白色对象的情况了,满足强三色不变式,在插入指针 f 时,将 C 对象标记为灰色。

image-20231222095343409

例如上图,在 A 中插入指向 C 的指针 f 时,将 C 标记为灰色。

Go 1.5 版本使用的 Dijkstra 写屏障就是这个原理。

如果对栈上的写做拦截,那么流程代码会非常复杂,并且性能下降会非常大,得不偿失。(栈的操作非常频繁,而且性能很高。)

根据局部性的原理来说,其实程序跑起来,大部分的操作在栈上,函数参数、函数调用导致的压栈出栈、局部变量、协程栈,如果这些都弄写屏障,那么可想而知,根本就不现实,复杂度和性能就是越不过去的坎。

因此 Go 选择只在堆上的内存设置写屏障。

image-20231222095618791

1
2
3
4
5
6
7
添加下游对象(当前下游对象slot, 新下游对象ptr) {   
//1
标记灰色(新下游对象ptr)

//2
当前下游对象slot = 新下游对象ptr
}

场景

1
2
A.添加下游对象(nil, B)   //A 之前没有下游, 新添加一个下游对象B, B被标记为灰色
A.添加下游对象(C, B) //A 将下游对象C 更换为B, B被标记为灰色
  1. 内存屏障知识对应一段特殊的代码
  2. 内存屏障这段代码在编译期间生成
  3. 内存屏障本质上在运行期间拦截内存写操作,相当于一个 hook 调用

Go 团队在实现上选择了在标记阶段完成暂停程序,将所有栈对象标记为灰色并重新扫描,在活跃 goroutine 非常多的程序中,重新扫描的过程需要占用 10~100ms 的时间。

使用写屏障时的GC 过程

Go 选择仅对堆上的指针插入增加写屏障,这样就会出现在扫描结束后,栈上仍存在应用白色对象的情况,这时的栈是灰色的,不满足三色不变式,所以需要对栈进行重新扫描使其变黑,完成剩余对象的标记,这个过程需要 STW。

image-20231222100534938

  1. 初始化 GC 任务,包括开启写屏障(write barrier)和开启辅助 GC(mutator assist),统计 root 对象的任务数量等,这个过程需要短暂 STW。

  2. 扫描所有 root 对象,包括全局指针和 goroutine(G)栈上的指针(扫描对应 G 栈时需停止该 G),将其加入标记队列(灰色队列),并循环处理灰色队列的对象,直到灰色队列为空,该过程后台并行执行。插入写屏障时,只保证堆上的对象可以正确标记,但是无法确保栈上的对象在并行过程中产生新的对象,因此还需要 STW 阶段对栈上的内存 re-scan

  3. 完成标记工作,重新扫描(re-scan)全局指针和栈。

    因为 Markmutator 是并行的,所以在 Mark 过程中可能会有新的对象分配和指针赋值,这个时候就需要通过写屏障(write barrier)记录下来,re-scan再检查一下,这个过程也是会 STW 的。这次 STW 的时间大约是 10~100ms

  4. 按照标记结果回收所有的白色对象,该过程后台并行执行。

对未清扫的 span 进行清扫,只有上一轮的 GC 的清扫工作完成才可以开始新一轮的 GC。

如果发现扫描后回收的速度跟不上分配的速度它依然会把用户逻辑暂停,用户逻辑暂停了以后也就意味着不会有新的对象出现,同时会把用户线程抢过来加入到垃圾回收里面加快垃圾回收的速度。

这样一来原来的并发还是变成了 STW,还是得把用户线程暂停掉,要不然扫描和回收没完没了了停不下来,因为新分配对象比回收快,所以这种东西叫作辅助回收。

Yuasa 删屏障

删除屏障也是拦截写操作的,但是是通过保护灰色对象到白色对象的路径不会断来实现的。

image-20231222101805048

如上图例中,在删除指针 e 时,将对象 c 标记为灰色,这样 c 下游的所有白色对象,即使会被黑色对象应用,最终也还是会被扫描标记的,满足了弱三色不变式。

这种方式的回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮 GC 中被清理掉。例如上图中的 C 对象,由于从 B 指向 C 的指针被删除,这一轮 GC 过程中 C 不会被清理,但是下一轮对象 C 就会被清理。

image-20231222103525738

1
2
3
4
5
6
7
8
9
添加下游对象(当前下游对象slot, 新下游对象ptr) {
//1
if (当前下游对象slot是灰色 || 当前下游对象slot是白色) {
标记灰色(当前下游对象slot) //slot为被删除对象, 标记为灰色
}

//2
当前下游对象slot = 新下游对象ptr
}

场景

1
2
A.添加下游对象(B, nil)   //A对象,删除B对象的引用。  B被A删除,被标记为灰(如果B之前为白)
A.添加下游对象(B, C) //A对象,更换下游B变成C。 B被A删除,被标记为灰(如果B之前为白)

在删除指针 e 的时候,如果指向的对象 C 是灰色或者是白色,则将对象 C 标记为灰色。

混合屏障

插入屏障和删除屏障各有优点和短板:

Dijkstra 的插入写屏障在标记开始时无需 STW(启用写屏障需要短暂 STW,但是标记过程不需要 STW),可直接开始,并发进行,但结束时需要 STW 来重新扫描栈,标记栈上引用的白色对象的存活

Yuasa 的删除写屏障则需要在 GC 开始时 STW 扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象,但结束时无需 STW。

Golang v1.8版本引入了混合写屏障机制,避免了对栈 re-scan 的过程,极大的减少了 STW 的时间。结合了两者的优点。

混合写屏障满足的是变形的弱三色不变式,同样允许黑色对象引用白色对象,白色对象处于灰色保护状态,但是只由堆上的灰色对象保护。

image-20231222103057305

  1. GC 开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需 STW)
  2. GC 期间,任何在栈上创建的新对象,均为黑色
  3. 被删除的对象标记为灰色
  4. 被添加的对象标记为灰色

image-20231222103622421

1
2
3
4
5
6
7
8
9
10
添加下游对象(当前下游对象slot, 新下游对象ptr) {
//1
标记灰色(当前下游对象slot) //只要当前下游对象被移走,就标记灰色

//2
标记灰色(新下游对象ptr)

//3
当前下游对象slot = 新下游对象ptr
}

shade(*slot) 是删除写屏障的变形,例如:

  • 一个堆上的灰色对象 B,引用白色对象 C
  • 在 GC 并发运行的过程中,如果栈已扫描置黑,而赋值器将指向 C 的唯一指针 e 从 B 中删除,并让栈上其他对象 A 引用它,这时,写屏障会在删除指向白色对象 C 的指针 e 的时候就将 C 对象置灰,就可以保护下来了,且它下游的所有对象都处于被保护状态
  • 如果对象 B 在栈上,引用堆上的白色对象 C,将其引用关系删除,且新增一个黑色对象 A 到对象 C 的应用,那么就需要通过 shade(ptr) 来保护了,在指针 f 插入黑色对象时会触发对对象 C 的置灰操作。如果栈已经被扫描过了,那么栈上引用的对象都是灰色或受灰色保护的白色对象了,所以就没有必要再进行这步操作。

由于结合 Yuasa 的删除写屏障和 Dijkstra 的插入写屏障的优点,只需要在开始时并发扫描各个 goroutine 的栈,使其变黑并一直保持,这个过程不需要 STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行 re-scan 操作了,减少了 STW 的时间。

image-20231222104551165

  • 第一阶段:gc开始

    1. 快速 STW(上图中没有表示出来)
    2. 每个 P 启动一个 mark worker goroutine 用于标记(用于第二阶段工作
    3. 启动 gc write barrier,记录一下后续在进行 marking 时被修改的指针
    4. 找到所有 rootsstackheapglobal vars)并加入标记队列
    5. start the world,进入第二阶段
  • 第二阶段:marking(上图中没有表示出来)

    1. 从标记队列中取出对象,标记为黑色(不能 GC)
      
    2. 然后检查是否有指向另外一个对象,是,则加入标记队列
      
    3. go 中分配对象会根据是否是指针分别放到不同的 `span` 中,根据这个如果 `span` 是指针 `span`,那么就需要继续 `scan` 下一个对象,否则停止该路 `scan`,取队列中下一个对象继续 `scan`
      
    4. 扫描过程中,如果用户修改代码,那么会触发(堆上的)写屏障,将对象标记为灰色,并加入单独的扫描队列中
      

    注意:

    1. 这个阶段是和用户的程序并发一起运行的
    2. 所有进入队列中的对象逻辑上就认为是灰色的
    3. 从队列中出来的需要 scan 的对象被标记为黑色,将 bitmap 中对应的 gcmarkBits 设为1
  • 第三阶段:处理 marking 过程中修改的指针(图中下半部分)

    1. 快速 STW,将 gc write barrier 记录的所有修改的指针也加入标记队列进行一轮标记

    注意:

    1. 这个阶段不是和用户程序并行的
    2. 扫描完了以后 start the world
  • 第四阶段:sweep

    到了这一阶段,所有内存要么是黑色的要么是白色的,清除所有白色的即可。

为了移除栈的重扫描过程,出了引入混合写屏障之外,在垃圾收集的标记阶段,我们还需要将创建的所有堆上新对象都标记成黑色,防止新分配的栈内存和堆内存中的对象被错误地回收,因为栈内存在标记阶段最终都会变为黑色,所以不再需要重新扫描栈空间。

Sweep

Sweep 让 Go 知道哪些内存可以重新分配使用,然而,Sweep 过程并不会处理释放的对象内存置为 0(zeroing the memory)。

在 内存分配时,mspan 的结构体中维护了两个位图字段(两个字段完全一致,为了后续 GC 之后,直接替换),记录已经分配的和需要gc的。

image-20231229105910802

而是在分配重新使用的时候,重新 reset bit

image-20231222105747171

每个 span 内有一个 bitmap 叫作 allocBits,它表示上一次 GC 之后每一个 object 的分配情况。1:表示已分配;2:表示未使用或释放。

image-20231222105854334

内部还使用了 uint64 allocCache(deBruijin),加速寻找 freeobject

在清扫阶段,GC 会启动去释放不再被使用的内存。在标记期间,GC 会用一个位图 gcmarkBits 来跟踪在使用中的内存。

image-20231222110545812

正在被使用的内存被标记为黑色,然而当前执行并不能够到达的那些内存会保持为白色。

现在,可以使用 gcmarkBits 精确查看可用于分配的内存。Go 使用 gcmarkBits 赋值了 allocBits,这个操作就是内存清理。

然而必须每个 span 都来一次类似的处理,需要耗费大量时间。Go 的目标是在清理内存时不阻碍执行,并为此提供了两种策略。

  • 在后台启动一个 worker 等待清理内存,一个一个 mspan 处理

    当开始运行程序时,Go 将设置一个后台运行的 Worker (唯一的任务就是去清理内存),它将进入睡眠状态并等待内存段扫描。

    image-20231222111027725

  • 当申请分配内存时候 lazy 触发

    当应用程序 goroutine 尝试在堆内存中分配新内存时,会触发该操作。清理导致的延迟和吞吐量降低被分散到每次内存分配时。

    image-20231222111321799

清理内存段的第二种方式是即时执行。但是,由于这些内存段已经被分发到每一个处理器 P 的本地缓存 mcache 这种,因此很难追踪首先清理哪些内存。这就是为什么 Go 首先将所有内存段移动到 mcentral 的原因。然后,它将会让本地缓存 mcache 再次请求它们,去即时清理。

即时扫描确保所有内存段都会得到清理(节省资源),同时不会阻塞程序执行。

由于后台只有一个 worker 在清理内存块,清理过程可能会花费一些时间。但是,我们可能想知道如果另一个 GC 周期在写一次清理过程中启动会发生什么。在这种情况下,这个运行 GC 的 goroutine 就会在开始标记阶段前去协助完成剩余的清理工作。

总结

  1. Go v1.3:普通标记清除发,整体过程需要启动 STW,效率极低
  2. Go v1.5:三色标记法,堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要 STW),效率普通
  3. Go v1.8:三色标记法,混合写屏障机制,栈空间不启动杆,堆空间驱动。整个过程几乎不需要 STW,效率较高。

Stop The World

在垃圾回收机制(GC)中,Stop the World(STW)是一个重要阶段。顾名思义,在 STW 阶段,当前运行的所有程序将被暂停,扫描内存的 root 节点和添加写屏障(write barrier

image-20231222112021921

这个阶段的第一步,是抢占所有正在运行的 goroutine ,被抢占之后(以前是函数调用触发,但是可能会出现死循环中无法GC,后面改成信号量抢占),这些 goroutine 会被悬停在一个相对安全的状态。(由 p0 来抢占所有的 goroutine

image-20231222112101588

处理器 P(无论是正在运行代码的处理器还是已在 idle 列表中的处理器),都会被标记成停止状态(stopped),不再运行任何代码。

image-20231222112430332

调度器把每个处理器的 M 从各自对应的处理器 P 分离出来,放到 idle 列表中去。

image-20231222112433774

对于 goroutines 本身,他们会被放到一个全局队列中等待。

Pacing

image-20231222112817623

P1 专门用于垃圾回收。现在垃圾收集器可以开始标记阶段。应用程序可以在 P2,P3,P4上继续执行。

这意味着垃圾收集器的影响已最小化到当前 CPU 的 25%。如果在垃圾收集过程中,P1 在堆内存达到极限之前无法完成标记工作(因为应用程序可能在大量分配内存),应用程序 goroutine 成为 Mark Assist(协助标记)中的时间长度与它申请的堆内存成正比。Mark Assist 有助于更快地完成垃圾收集。

image-20231222113449060

  • 运行时有 GC Percentage 的配置选项,默认情况下为 100

    此值表示在下一次垃圾手机必须启动之前可以分配多少新内存的比率。

    将 GC 百分比设置为100意味着,基于在垃圾收集完成后标记为活动的堆内存量,下次垃圾收集前,堆内存使用可以增加 100%

    image-20231222113231890

    正在使用的内存,例如 2MB

    image-20231222113247773

    最多可重新分配 2MB 内存

  • 如果超过 2分钟没有触发,sysmon 会强制触发 GC

使用环境变量 GODEBUG 和 gctrace = 1 选项生成 GC trace:GODEBUG=gctrace=1 ./app

Reference

[典藏版]Golang三色标记、混合写屏障GC模式图文全分析

Go 语言内存管理(四):垃圾回收

Golang源码探索(三) GC的实现原理

搞懂Go垃圾回收

Visualizing Garbage Collection Algorithms

理解 Go 的 GC 设计

golang 垃圾回收(二)屏障技术

深度剖析 Golang 的 GC 扫描对象实现

Garbage Collection In Go : Part I - Semantics

Garbage Collection In Go : Part II - GC Traces

Garbage Collection In Go : Part III - GC Pacing

Go的三色标记GC

深入Golang Runtime之Golang GC的过去,当前与未来

8.5 免清扫式位图技术

Golang垃圾回收 屏障技术

golang gc 简明过程(基于go 1.14)

深入理解Go-垃圾回收机制

深入理解GO语言:GC原理及源码分析

关于Golang GC的一些误解–真的比Java GC更领先吗?

dgraph-io/badger Node Pooling