Skip to main content
 首页 » 编程设计

GO中的GC

2022年07月19日153artech

go中的垃圾回收

前言

对于go中的垃圾回收,总是不太熟悉。来具体分析下,具体的流程。本次探究的go版本go version go1.13.15 darwin/amd64

垃圾回收

垃圾回收(Garbage Collection,简称GC)是编程语言中提供的自动的内存管理机制,自动释放不需要的对象,让出存储器资源,无需程序员手动执行。

当程序向操作系统申请的内存不再需要时,垃圾回收主动将其回收并供其他代码进行内存申请时候复用,或者将其归还给操作系统,这种针对内存级别资源的自动回收过程,即为垃圾回收。而负责垃圾回收的程序组件,即为垃圾回收器。

go中的垃圾回收方式

所有的 GC 算法其存在形式可以归结为追踪(Tracing)和引用计数(Reference Counting)这两种形式的混合运用。

  • 追踪式 GC

从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。Go、 Java、V8 对 JavaScript 的实现等均为追踪式 GC。

  • 引用计数式 GC

每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。因为此方法缺陷较多,在追求高性能时通常不被应用。Python、Objective-C 等均为引用计数式 GC。

目前比较常见的 GC 实现方式包括:

追踪式

  • 标记清扫:从根对象出发,将确定存活的对象进行标记,并清扫可以回收的对象;
  • 标记整理:为了解决内存碎片问题而提出,在标记过程中,将对象尽可能整理到一块连续的内存上;
  • 增量式:将标记与清扫的过程分批执行,每次执行很小的部分,从而增量的推进垃圾回收,达到近似实时、几乎无停顿的目的;
  • 增量整理:在增量式的基础上,增加对对象的整理过程;
  • 分代式:将对象根据存活时间的长短进行分类,存活时间小于某个值的为年轻代,存活时间大于某个值的为老年代,永远不会参与回收的对象为永久代。并根据分代假设(如果一个对象存活时间不长则倾向于被回收,如果一个对象已经存活很长时间则倾向于存活更长时间)对对象进行回收;

引用计数

  • 引用计数:根据对象自身的引用计数来回收,当引用计数归零时立即回收;

go中目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。

原因:

1、对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于 tcmalloc,基本上没有碎片问题。并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于 tcmalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。

2、分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。

go中使用的是三色标记法

三色标记法

三色标记,通过字面意思我们就可以知道它由3种颜色组成:

回收器通过将对象图划分为三种状态来指示其扫描过程。

白色对象

白色 White:潜在的垃圾,其内存可能会被垃圾收集器回收,如果扫描完成后,对象依然还是白色的,说明此对象是垃圾对象。

灰色对象

灰色 Gary:活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;

黑色

黑色 Black:活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象;

三色标记规则:黑色不能指向白色对象。即黑色可以指向灰色,灰色可以指向白色。

根对象

在介绍三色标记法之前,首先了解下什么是根对象。

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

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

在GC的标记阶段首先需要标记的就是"根对象", 从根对象开始可到达的所有对象都会被认为是存活的。
根对象包含了全局变量, 各个G的栈上的变量等, GC会先扫描根对象然后再扫描根对象可到达的所有对象。

在垃圾收集器开始工作时,程序中不存在任何的黑色对象,垃圾收集的根对象会被标记成灰色,垃圾收集器只会从灰色对象集合中取出对象开始扫描,当灰色集合中不存在任何对象时,标记阶段就会结束。

gc

三色标记垃圾收集器的工作原理很简单,我们可以将其归纳成以下几个步骤:

1、从灰色对象的集合中选择一个灰色对象并将其标记成黑色;
2、将黑色对象指向的所有对象都标记成灰色,保证该对象和被该对象引用的对象都不会被回收;
3、重复上述两个步骤直到对象图中不存在灰色对象;

三色标记结束之后,里面只剩下白色和黑色的,最后回收掉白色的对象。

STW

因为用户程序可能在标记执行的过程中修改对象的指针,所以三色标记清除算法本身是不可以并发或者增量执行的。

gc

比如上面所示的三色标记过程中,用户程序建立了从 A 对象到 D 对象的引用,但是因为程序中已经不存在灰色对象了,所以 D 对象会被垃圾收集器错误地回收。

本来不应该被回收的对象却被回收了,这会给我们的程序带来不可预知的问题。

什么是STW?

STW 是 StoptheWorld 的缩写,即万物静止,是指在垃圾回收过程中为了保证实现的正确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的一段过程。

STW的过程有明显的资源浪费,对所有的用户程序都有很大影响。早期 Go 对垃圾回收器的实现中 STW 长达几百毫秒,尽管 STW 如今已经优化到了半毫秒级别以下,但是STW的影响还是存在的。

想要并发或者增量地标记对象还是需要使用屏障技术。

屏障技术

在Golang中使用并发的垃圾回收,也就是多个赋值器与回收器并发执行,与此同时,应用屏障技术来保证回收器的正确性。其原理主要就是破坏上述两个条件之一。

内存屏障技术是一种屏障指令,它可以让 CPU 或者编译器在执行内存相关操作时遵循特定的约束,目前的多数的现代处理器都会乱序执行指令以最大化性能,但是该技术能够保证代码对内存操作的顺序性,在内存屏障前执行的操作一定会先于内存屏障后执行的操作。

想要在并发或者增量的标记算法中保证正确性,我们需要达成以下两种三色不变性(Tri-color invariant)中的任意一种:

弱三色不变式

所有被黑色对象引用的白色对象都处于灰色保护状态(直接或间接从灰色对象可达)。 强三色不变式:不存在黑色对象到白色对象的指针。

强三色不变式

黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;

强三色不变式很好理解,强制性的不允许黑色对象引用白色对象即可。而弱三色不变式中,黑色对象可以引用白色对象,但是这个白色对象仍然存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。

插入屏障

Dijkstra 在 1978 年提出了插入写屏障,通过如下所示的写屏障,用户程序和垃圾收集器可以在交替工作的情况下保证程序执行的正确性:

writePointer(slot, ptr): 
    shade(ptr) 
    *slot = ptr 

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

比如上面黑色A指向白色D,A在引用D的时候直接将D标记为灰色就可以了。

gc

1、垃圾回收器将A对象标记为黑色,然后A对象指向的B对象标记为灰色;
2、用户程序修改A对象的指针,将原本指向B对象的指针,指向了C对象,触发写屏障,根据强三色不变式,黑色不能直接指向白色,更改C对象的颜色为灰色;
3、垃圾收集器依次遍历程序中的其他灰色对象,将它们分别标记成黑色;

插入式的 Dijkstra 写屏障虽然实现非常简单并且也能保证强三色不变性,但是它也有很明显的缺点。因为栈上的对象在垃圾收集中也会被认为是根对象,所以为了保证内存的安全,Dijkstra 必须为栈上的对象增加写屏障或者在标记阶段完成重新对栈上的对象进行扫描,这两种方法各有各的缺点,前者会大幅度增加写入指针的额外开销,后者重新扫描栈对象时需要暂停程序。

在Golang中,对栈上指针的写入添加写屏障的成本很高,所以Go选择仅对堆上的指针插入增加写屏障,这样就会出现在扫描结束后,栈上仍存在引用白色对象的情况,这时的栈是灰色的,不满足三色不变式,所以需要对栈进行重新扫描使其变黑,完成剩余对象的标记,这个过程需要STW。这期间会将所有goroutine挂起,当有大量应用程序时,时间可能会达到10~100ms。

删除屏障

Yuasa 在 1990 年的论文 Real-time garbage collection on general-purpose machines 中提出了删除写屏障,因为一旦该写屏障开始工作,它就会保证开启写屏障时堆上所有对象的可达,所以也被称作快照垃圾收集(Snapshot GC)。

writePointer(slot, ptr): 
    if (isGery(slot) || isWhite(slot)) 
        shade(*slot) 
    *slot = ptr 

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

gc

1、首先将A对象标记成黑色,然后A对象指向的B对象标记为灰色;
2、程序将A对象指向C对象,触发删除写屏障,根据若三色不变式,因为C对象有灰色对象B的指向,所以不用做改变;
3、接着程序删除B对象对C的指向,触发删除写屏障,因为对象D不存在直接和间接的灰色可达对象,需要改变对象C的颜色为灰色;
4、垃圾收集器依次遍历,标记GC。

上面的步骤2违反了强三色不变式,黑色对象直接指向了白色对象。接下来的步骤三违反了弱三色不变式,对象D没有一个直接或间接可达的灰色对象。通过对C重新着色,来保证C和D对象的安全。

混合写屏障

插入写屏障和删除写屏障的短板:

插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;

删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。

在 Go 语言 v1.7 版本之前,运行时会使用 Dijkstra 插入写屏障保证强三色不变性,但是运行时并没有在所有的垃圾收集根对象上开启插入写屏障。因为 Go 语言的应用程序可能包含成百上千的 Goroutine,而垃圾收集的根对象一般包括全局变量和栈对象,如果运行时需要在几百个 Goroutine 的栈上都开启写屏障,会带来巨大的额外开销,所以 Go 团队在实现上选择了在标记阶段完成时暂停程序、将所有栈对象标记为灰色并重新扫描,在活跃 Goroutine 非常多的程序中,
重新扫描的过程需要占用 10 ~ 100ms 的时间。

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

伪代码

writePointer(slot, ptr): 
    shade(*slot) 
    if current stack is grey: 
        shade(ptr) 
    *slot = ptr 

GO中GC的流程

1、准备阶段

STW,初始化标记任务,启用写屏障

2、标记阶段 GCMark

与赋值器并发执行,写屏障处于开启状态

1、将状态切换至 _GCmark、开启写屏障、用户程序协助(Mutator Assiste)并将根对象入队;
2、恢复执行程序,标记进程和用于协助的用户程序会开始并发标记内存中的对象,写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象都会被直接标记成黑色;
3、开始扫描根对象,包括所有 Goroutine 的栈、全局对象以及不在堆中的运行时数据结构,扫描 Goroutine 栈期间会暂停当前处理器;
4、依次处理灰色队列中的对象,将对象标记成黑色并将它们指向的对象标记成灰色;
5、使用分布式的终止算法检查剩余的工作,发现标记阶段完成后进入标记终止阶段;

3、标记终止阶段

STW, 保证一个周期内标记任务完成,停止写屏障

4、清理阶段

并发执行

1、将状态切换至 _GCoff 开始清理阶段,初始化清理状态并关闭写屏障;
2、恢复用户程序,所有新创建的对象会标记成白色;
3、后台并发清理所有的内存管理单元,当 Goroutine 申请新的内存管理单元时就会触发清理;

GC的触发时机

Go 语言中对 GC 的触发时机存在两种形式:

  • 主动触发,通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。
  • 被动触发,分为两种方式:
    1、使用系统监控,当超过两分钟没有产生任何 GC 时,强制触发 GC。
    2、使用步调(Pacing)算法,其核心思想是控制内存增长的比例。

例如:

  • 内存大小阈值, 内存达到上次gc后的2倍
  • 达到定时时间 ,2m interval

阈值是由一个gc percent的变量控制的,当新分配的内存占已在使用中的内存的比例超过gcprecent时就会触发。比如一次回收完毕后,内存的使用量为5M,那么下次回收的时机则是内存分配达到10M的时候。也就是说,并不是内存分配越多,垃圾回收频率越高。 如果一直达不到内存大小的阈值呢?这个时候GC就会被定时时间触发,比如一直达不到10M,那就定时(默认2min触发一次)触发一次GC保证资源的回收。

如果内存分配速度超过了标记清除的速度怎么办?

如果在后台执行的垃圾收集器不够快,应用程序申请内存的速度超过预期,运行时就会让申请内存的应用程序辅助完成垃圾收集的扫描阶段,在标记和标记终止阶段结束之后就会进入异步的清理阶段,将不用的内存增量回收。

并发标记会设置一个标志,并在 mallocgc 调用时进行检查。当存在新的内存分配时,会暂停分配内存过快的那些 goroutine,并将其转去执行一些辅助标记(Mark Assist)的工作,从而达到放缓继续分配、辅助 GC 的标记工作的目的。

如何观察GC

1、使用GODEBUG=gctrace=1

$ GODEBUG=gctrace=1  go run main.go 

测试的代码片段

package main 
 
import "sync" 
 
func main() { 
 
	wg := sync.WaitGroup{} 
	wg.Add(10) 
	for i := 0; i < 10; i++ { 
		go func(wg *sync.WaitGroup) { 
			var counter int 
			for i := 0; i < 1e10; i++ { 
				counter++ 
			} 
			wg.Done() 
		}(&wg) 
	} 
 
	wg.Wait() 
 
} 

来分析下结果

gc 1 @0.054s 5%: 0.024+37+0.48 ms clock, 0.098+4.5/12/53+1.9 ms cpu, 4->4->3 MB, 5 MB goal, 4 P 
字段 含义
gc 1 第一个gc周期
0.054s 程序开始后的 0.054 秒
5% 该 GC 周期中 CPU 的使用率
0.024 标记开始时, STW 所花费的时间(wall clock)
37 标记过程中,并发标记所花费的时间(wall clock)
0.48 标记终止时, STW 所花费的时间(wall clock)
0.098 标记开始时, STW 所花费的时间(cpu time)
4.5 标记过程中,标记辅助所花费的时间(cpu time)
12 标记过程中,标记辅助所花费的时间(cpu time)
53 标记过程中,GC 空闲的时间(cpu time)
1.9 标记终止时, STW 所花费的时间(cpu time)
4 标记开始时,堆的大小的实际值
4 标记结束时,堆的大小的实际值
3 标记结束时,标记为存活的对象大小
5 标记结束时,堆的大小的预测值
4 P 的数量

GC如何优化

多余gc的优化主要从下面几个方面考虑

1、控制内存分配的速度,限制 goroutine 的数量,从而提高赋值器对 CPU 的利用率。
2、减少并复用内存,例如使用 sync.Pool 来复用需要频繁创建临时对象,例如提前分配足够的内存来降低多余的拷贝。
3、需要时,增大 GOGC 的值,降低 GC 的运行频率。

GO中GC的演进过程

  • v1.0 — 完全串行的标记和清除过程,需要暂停整个程序;
  • v1.1 — 在多核主机并行执行垃圾收集的标记和清除阶段;
  • v1.3 — 运行时基于只有指针类型的值包含指针的假设增加了对栈内存的精确扫描支持,实现了真正精确的垃圾收集;
  • v1.5 — 实现了基于三色标记清扫的并发垃圾收集器;
  • v1.6 — 实现了去中心化的垃圾收集协调器;
  • v1.7 — 通过并行栈收缩将垃圾收集的时间缩短至 2ms 以内;
  • v1.8 — 使用混合写屏障将垃圾收集的时间缩短至 0.5ms 以内;
  • v1.9 — 彻底移除暂停程序的重新扫描栈的过程;
  • v1.10 — 更新了垃圾收集调频器(Pacer)的实现,分离软硬堆大小的目标;
  • v1.12 — 使用新的标记终止算法简化垃圾收集器的几个阶段;
  • v1.13 — 通过新的 Scavenger 解决瞬时内存占用过高的应用程序向操作系统归还内存的问题;
  • v1.14 — 替代了仅存活了一个版本的 scavenger,全新的页分配器,优化分配内存过程的速率与现有的扩展性问题,并引入了异步抢占,解决了由于密集循环导致的 STW 时间过长的问题;

总结

上面总结了go中gc的一些场景,go使用的是三色标记的回收策略,结合混合写屏障来保证并发回收的安全性,但是在时机的回收过程中还是需要STW的。

参考

【Golang垃圾回收 屏障技术】https://zhuanlan.zhihu.com/p/74853110
【Garbage Collection In Go : Part I - Semantics】https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html
【深入理解Go语言-GC与逃逸分析】https://zhuanlan.zhihu.com/p/103056375
【Go GC 20 问】https://zhuanlan.zhihu.com/p/109431787
【Golang源码探索(三) GC的实现原理】https://www.cnblogs.com/zkweb/p/7880099.html
【【golang】变量的stack/heap分配与逃逸分析不解之情】https://www.jianshu.com/p/8a80d50d2f9c
【golang的gc回收针对堆还是栈?变量内存分配在堆还是栈?】https://www.mscto.com/blockchain/264512.html
【写屏障技术】https://golang.design/under-the-hood/zh-cn/part2runtime/ch08gc/barrier/
【Golang三色标记、混合写屏障GC模式图文全分析】https://mp.weixin.qq.com/s/G7id1bNt9QpAvLe7tmRAGw
【垃圾收集器】https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-garbage-collector/
【golang gc 简明过程(基于go 1.14)】https://zhuanlan.zhihu.com/p/92210761
【如何观察 Go GC】https://www.bookstack.cn/read/qcrao-Go-Questions/195663


本文参考链接:https://www.cnblogs.com/ricklz/p/14155443.html
阅读延展