go 的垃圾回收
案例分析
package main
import (
"math"
"sync"
"time"
)
func doAllocate(nKB int, wg *sync.WaitGroup) {
var slice []byte
for i := 0; i < nKB; i++ {
t := make([]byte, 1024) // 1KB
slice = append(slice, t...)
// 大约会执行 50 秒,方便观察内存增长
time.Sleep(time.Millisecond)
}
wg.Done()
println("doAllocate done")
}
func doIdleAdd(n int64, wg *sync.WaitGroup) {
var res int64
for i := int64(0); i < n; i++ {
res += i
}
wg.Done()
println("doIdleAdd done")
}
func main() {
// runtime.GOMAXPROCS(runtime.NumCPU()) // needed before go 1.5
wg := new(sync.WaitGroup)
wg.Add(2)
go doAllocate(50*1024, wg) // 申请内存空间 50MB, 约50秒
t := int64(math.Pow(10, 11))
go doIdleAdd(t, wg) // 执行加法,大约会执行 30 秒,方便观察运行情况
wg.Wait()
}
- 两个goroutine,一个不停地执行加法运算,大约会执行 30 秒。另一个 goroutine 会不停地执行内存分配,最多会分配 50MB 的内存,大约50秒
- 开启goroutine并发后,预计50秒左右完成程序,略大于50M内存,但是内存占用100M左右,同时整个程序运行80秒
原因:由于GC-stop the world 机制,即所有的goroutine必须停下来
- 为方便区分两个goroutine,暂且分别叫它们为,申请内存goroutine,加法计算goroutine。开始并发后,刚开始两个goroutine并发运行,中期GC时,申请内存的goroutine for循环内有函数调用,被停止,而计算加法goroutine,for循环内只是单纯的计算,并没有其他函数,无法进行抢占式调度,所以加法goroutine仍在运行。
所以GC只能等加法goroutine运行结束,此时此刻,实质只有计算加法的goroutine在运行,并发已经不存在。
抢占式调度
- 这个抢占式调度的原理则是在每个函数或方法的入口,加上一段额外的代码,让runtime有机会检查是否需要执行抢占调度。这种解决方案只能说局部解决了“饿死”问题,对于没有函数调用,纯算法循环计算的G,scheduler依然无法抢占。
- Go语言的垃圾回收器是stoptheworld的。如果垃圾回收器想要运行了,那么它必须先通知其它的goroutine合作停下来。这会造成较长时间的垃圾回收等待时间。我们考虑一种很极端的情况,其它的goroutine都停下来了,除了有一个没有停,那么垃圾回收就会一直等待。抢占式调度可以解决这种问题,在抢占式情况下,不停goroutine是否合作,它都会被yield。
优化:
- 1.doIdleAdd 的 for 循环中增加一行代码time.Sleep(0)
- 2.调整GOGC值,改变回收速度,该值即是,新分配的数据与上一个收集之后剩余的实时数据的比率达到该百分比时,触发垃圾收集
1.垃圾回收是什么
GC 不回收什么?
为了解释垃圾回收是什么,我们先来说说 GC 不回收什么。在我们程序中会使用到两种内存,分别为堆(Heap)和栈(Stack),而 GC 不负责回收栈中的内存。那么这是为什么呢?
主要原因是栈是一块专用内存,专门为了函数执行而准备的,存储着函数中的局部变量以及调用栈。除此以外,栈中的数据都有一个特点——简单。比如局部变量就不能被函数外访问,所以这块内存用完就可以直接释放。正是因为这个特点,栈中的数据可以通过简单的编译器指令自动清理,也就不需要通过 GC 来回收了。
为什么需要垃圾回收?
现在我们知道了垃圾回收只负责回收堆中的数据,那么为什么堆中的数据需要自动垃圾回收呢?
其实早期的语言是没有自动垃圾回收的。比如在 C 语言中就需要使用 malloc/free 来人为地申请或者释放堆内存。这种做法除了增加工作量以外,还容易出现其他问题[1]。
一种可能是并发问题,并发执行的程序容易错误地释放掉还在使用的内存。一种可能是重复释放内存,还有可能是直接忘记释放内存,从而导致内存泄露等问题。而这类问题不管是发现还是排查往往会花费很多时间和精力。所以现代的语言都有了这样的需求——一个自动内存管理工具。
什么是垃圾回收?
看到这里,垃圾回收的定义也就十分清楚了。当我们说垃圾回收(GC garbage collection)的时候,我们其实说的是自动垃圾回收(Automatic Garbage Collection),一个自动回收堆内存的工具。所以垃圾回收一点也不神奇,它只是一种工具,可以更便捷更高效地帮助程序员管理内存。
2.三色标记法
追踪式垃圾回收(Tracing garbage collection)
主流的两类垃圾回收算法有两种,分别是追踪式垃圾回收算法[1]和引用计数法( Reference counting )。而三色标记法是属于追踪式垃圾回收算法的一种。
追踪式算法的核心思想是判断一个对象是否可达,因为一旦这个对象不可达就可以立刻被 GC 回收了。那么我们怎么判断一个对象是否可达呢?很简单,第一步找出所有的全局变量和当前函数栈里的变量,标记为可达。第二步,从已经标记的数据开始,进一步标记它们可访问的变量,以此类推。
为什么需要三色标记法?
在三色标记法之前有一个算法叫 Mark-And-Sweep(标记清扫),这个算法就是严格按照追踪式算法的思路来实现的。这个算法会设置一个标志位来记录对象是否被使用。最开始所有的标记位都是 0,如果发现对象是可达的就会置为 1,一步步下去就会呈现一个类似树状的结果。等标记的步骤完成后,会将未被标记的对象统一清理,再次把所有的标记位设置成 0 方便下次清理。
这个算法最大的问题是 GC 执行期间需要把整个程序完全暂停,不能异步进行 GC 操作。因为在不同阶段标记清扫法的标志位 0 和 1 有不同的含义,那么新增的对象无论标记为什么都有可能意外删除这个对象。对实时性要求高的系统来说,这种需要长时间挂起的标记清扫法是不可接受的。所以就需要一个算法来解决 GC 运行时程序长时间挂起的问题,那就三色标记法。
三色标记法好在哪里?
相比传统的标记清扫算法,三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC。
三色标记法过程。
- 首先将对象用三种颜色表示,分别是白色、灰色和黑色。
- 最开始所有对象都是白色的,然后把其中全局变量和函数栈里的对象置为灰色。
- 第二步把灰色的对象全部置为黑色,然后把原先灰色对象指向的变量都置为灰色,
- 以此类推。等发现没有对象可以被置为灰色时,所有的白色变量就一定是需要被清理的垃圾了。
三色标记法因为多了一个白色的状态来存放不确定的对象,所以可以异步地执行。当然异步执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative(假阴性)的算法。
3.一次完整回收过程
1)Go 执行三色标记前,需要先做一个准备工作——打开 Write Barrier。
Write Barrier
那么 Write Barrier[1]是什么呢?我们知道三色标记法是一种可以并发执行的算法。所以在运行过程中程序的函数栈内可能会有新分配的对象,那么这些对象该怎么通知到 GC,怎么给他们着色呢?这个时候就需要我们的 Write Barrier 出马了。Write Barrier 主要做这样一件事情,修改原先的写逻辑,然后在对象新增的同时给它着色,并且着色为”灰色“。因此打开了 Write Barrier 可以保证了三色标记法在并发下安全正确地运行。
Stop The World
不过在打开 Write Barrier 前有一个依赖,我们需要先停止所有的 goroutine,也就是所说的 STW(Stop The World)操作。那么接下来问题来了,GC 该怎么通知所有的 goroutine 停止呢 ?
我们知道,在停止 goroutine 的方案中,Go 语言采取的是合作式抢占模式(当前 1.13 及之前版本)。这种模式的做法是在程序编译阶段注入额外的代码,更精确的说法是在每个函数的序言中增加一个合作式抢占点。因为一个 goroutine 中通常有无数调用函数的操作,选择在函数序言中增加抢占点可以较好地平衡性能和实时性之间的利弊。在通常情况下,一次 Mark Setup 操作会在 10-30 微秒[3]之间。
意外情况
但是,因为 Go 语言选择了合作式抢占模式,所以总会有一些意外发生,比如我在第一篇文章中举得那个例子。在这个例子中,程序运行后内存会一直在增长,所以GC 判断需要执行一次垃圾回收。但是其中一个 goroutine 执行的 for 循环是一个存粹的加法的操作——整整运行 30 秒都没有函数调用。所以为了执行 GC 标记,就需要先 STW 并且打开 Write Barrier。但是因为没有函数调用,整个程序就只能等着那个 goroutine 运行完。也就出现了我们看到的现象同一时刻只有一个 goroutine 在运行着。
通常这个问题在我们的程序中是不会发生的,但是一旦发生了就会产生很大的影响。事实上 github 上确实有一些 issue 提到了这个问题,Go 官方也在尝试修复这个问题。一个正在 coding 的解决方案是采用非合作的 goroutine 抢占模式,关心具体进展的同学可以关注一下这个 issue[4]。
2)Marking 标记(Concurrent)
在第一阶段打开 Write Barrier 后,就进入第二阶段的标记了。Marking 使用的算法就是我们之前提到的三色标记法,这里不再赘述。不过我们可以简单了解一下标记阶段的资源分配情况。
在标记开始的时候,收集器会默认抢占 25% 的 CPU 性能,剩下的75%会分配给程序执行。但是一旦收集器认为来不及进行标记任务了,就会改变这个 25% 的性能分配。这个时候收集器会抢占程序额外的 CPU,这部分被抢占 goroutine 有个名字叫 Mark Assist。而且因为抢占 CPU的目的主要是 GC 来不及标记新增的内存,那么抢占正在分配内存的 goroutine 效果会更加好,所以分配内存速度越快的 goroutine 就会被抢占越多的资源。
除此以外 GC 还有一个额外的优化,一旦某次 GC 中用到了 Mark Assist,下次 GC 就会提前开始,目的是尽量减少 Mark Assist 的使用,从而避免影响正常的程序执行。
3)Mark Termination 标记结束(STW)
最重要的 Marking 阶段结束后就会进入 Mark Termination 阶段。这个阶段会关闭掉已经打开了的 Write Barrier,和 Mark Setup 阶段一样这个阶段也需要 STW。
标记结束阶段还需要做的事情是计算下一次清理的目标和计划,比如第二阶段使用了 Mark Assist 就会促使下次 GC 提早进行。如果想人为地减少或者增加 GC 的频率,那么我们可以用 GOGC 这个环境变量设置。一个小细节是在 Go 的文档[5]中有提及, Go 的 GC 有且只会有一个参数进行调优,也就是我们所说的 GOGC,目的是为了防止大家在一大堆调优参数中摸不着头脑。
通常情况下,标记结束阶段会耗时 60-90 微秒。
4)weeping 清理(Concurrent)
最后一个阶段就是垃圾清理阶段,这个过程是并发进行的。清扫的开销会增加到分配堆内存的过程中,所以这个时间也是无感知不会与垃圾回收的延迟相关联。
5)总结
一次完整的垃圾回收会分为四个阶段,分别是标记准备、标记、结束标记以及清理。在标记准备和标记结束阶段会需要 STW,标记阶段会减少程序的性能,而清理阶段是不会对程序有影响的。目前已经讲了这么多理论了,所以在下一篇文章中,我们会介绍一些实战案例。