原文地址: https://medium.com/a-journey-with-go/go-how-does-the-garbage-collector-watch-your-application-dbef99be2c35
Go 垃圾回收器会帮助开发人员自动释放不再使用的内存。 但是,跟踪内存并清理它可能会影响程序的性能。 Go 垃圾收集器旨在实现这些目标,并专注于:
- 尽量减少程序gc的次数,因为gc时程序会停止运行,只处理内存回收工作。
- 垃圾回收的时间不超过10毫秒
- 垃圾回收时间内不应该占用超过25%的CPU
这些都是雄心勃勃的目标,如果垃圾回收器从程序中记录了足够多的东西,它就能实现这些目标。
达到堆阈值
垃圾回收器将观察的第一个指标是堆的增长。 默认情况下,它会在堆大小加倍时运行。 这是一个在循环中分配内存的简单程序:
func BenchmarkAllocationEveryMs(b *testing.B) {
f, err := os.Create("watch.out")
if err != nil {
panic(err)
}
defer f.Close()
err = trace.Start(f)
if err != nil {
panic(err)
}
// need permanent allocation to clear see when the heap double its size
var s *[]int
tmp := make([]int, 1100000, 1100000)
s = &tmp
var a *[]int
for i := 0; i < b.N; i++ {
tmp := make([]int, 10000, 10000)
a = &tmp
time.Sleep(time.Millisecond)
}
_ = a
runtime.KeepAlive(s)
trace.Stop()
b.StopTimer()
}
测试命令
go test *.go -bench=BenchmarkAllocationEveryMs -benchmem -run=^$ -count=10 > watch.txt && benchstat watch.txt
测试结果
name time/op
AllocationEveryMs-8 1.26ms ± 1%
name alloc/op
AllocationEveryMs-8 91.4kB ± 0%
name allocs/op
AllocationEveryMs-8 2.00 ± 0%
查看trace图
go tool trace watch.out
(蓝色、粉色和红色是垃圾收集器的阶段,而棕色是与堆上的分配相关)
一旦堆的大小增加一倍,内存分配器就会触发垃圾收集器。 这可以通过打印循环信息的 GODEBUG=gctrace=1 来确认:
GODEBUG='gctrace=1' go test *.go -bench=BenchmarkAllocationEveryMs -benchmem -run=^$
gc 8 @0.273s 1%: 0.059+0.18+0.006 ms clock, 0.47+0/0.22/0.15+0.052 ms cpu, 16->16->8 MB, 17 MB goal, 8 P
gc 9 @0.407s 1%: 0.046+0.14+0.005 ms clock, 0.37+0/0.21/0.12+0.042 ms cpu, 16->16->8 MB, 17 MB goal, 8 P
gc 10 @0.539s 0%: 0.046+0.22+0.012 ms clock, 0.37+0/0.21/0.17+0.10 ms cpu, 16->16->8 MB, 17 MB goal, 8 P
gc9 是在程序运行 400 毫秒时运行,耗时 0.046+0.14+0.00 毫秒。 有趣的部分是 16->16->8 MB,它显示了垃圾回收之前正在使用的内存量以及垃圾回收之后的内存量。 我们清楚地看到,当gc8 将堆减少到 8MB 时,gc9 已经在 16MB 处被触发。
这个阈值的比例由环境变量 GOGC 定义,默认设置为 100%——这意味着垃圾收集器在堆大小增加 100% 时启动。 出于性能原因,并且为了避免不断启动一个循环,如果堆大小低于 4MB * GOGC,则不会触发垃圾收集器——当 GOGC 设置为 100% 时,不会在 4MB 以下触发。
达到时间阈值
垃圾回收器关注的第二个指标是两个垃圾回收之间的时间间隔。 如果超过两分钟没有触发,则强制执行一个循环。
代码示例:
func main() {
f, err := os.Create("watch.out")
if err != nil {
panic(err)
}
defer f.Close()
err = trace.Start(f)
if err != nil {
panic(err)
}
// need permanent allocation to clear see when the heap double its size
var s *[]int
tmp := make([]int, 1100000, 1100000)
s = &tmp
var a *[]int
for i := 0; i < 6; i++ {
tmp := make([]int, 10000, 10000)
a = &tmp
time.Sleep(30 * time.Second) //默认两分钟触发一次GC
}
_ = a
runtime.KeepAlive(s)
trace.Stop()
}
启动程序
GODEBUG='gctrace=1' go run main.go
GODEBUG给出的trace显示,两分钟后强制循环:
GC forced
gc 2 @120.011s 0%: 0.10+0.58+0.044 ms clock, 0.81+0/0.51/0.31+0.35 ms cpu, 8->8->8 MB, 17 MB goal, 8 P
GC forced
gc 10 @120.553s 0%: 0.49+1.9+0.007 ms clock, 3.9+0/3.8/7.9+0.063 ms cpu, 3->3->0 MB, 4 MB goal, 8 P
可以看到有两次的强制GC
垃圾回收由两个主要阶段组成:
- 标记仍在使用的内存
- 交换未标记为使用中的内存
在标记阶段,Go 必须确保它标记内存的速度比进行新分配的速度快。 事实上,如果收集器正在标记 4Mb 的内存,而在同一时间段内,程序正在分配相同数量的内存,则垃圾回收器将必须在完成后立即触发。
为了解决这个问题,Go 在标记内存的同时跟踪新的内存分配,并观察垃圾回收器何时到达负载。 当垃圾回收器被触发时,第一步开始。 它首先为每个将休眠的处理器准备一个 goroutine,等待标记阶段:
trace 工具可以显示这些 goroutine:
go tool trace watch.out
一旦这些 goroutine 生成,垃圾收回将开始标记阶段,该阶段将检查应该收集和清除哪个变量。 标记为 GC dedicated 的 goroutine 将在没有抢占的情况下运行标记,而标记为 GC idle 的 goroutine 正在工作,因为它们没有其他任何东西。 这些可以被抢占。
垃圾回收器现在准备标记不再使用的变量。 对于扫描的每个变量,它会增加一个计数器以跟踪当前工作并和剩余工作。 为了提高扫描速度和和满足分配要求,当一个 goroutine 在垃圾回收期间被安排工作时,Go 会将所需的分配需求与已经完成的扫描进行比较。如果比较结果未正向的则当前的goroutine不需要帮助。如果是逆向的则go会使用其他goroutine来帮忙。下图是该流程的逻辑图
因为上图没有请求协助的goroutine所以这里贴了原文的图。
CPU限制
Go 垃圾收集器的目标之一是不占用超过 25% 的 CPU。 这意味着 Go 不应在标记阶段分配四个以上的处理器。 实际上,这正是我们在前面的示例中看到的,同时只有八个处理器(逻辑CPU)中的两个 goroutine,完全专用于垃圾收集:
其他 goroutine 仅在它们无事可做时才会在标记阶段工作。 然而,在垃圾收集器的协助请求下,Go 程序最终可能会在高峰时间将超过 25% 的 CPU 专用于垃圾收集器, 这可能很少见,只会在高分配的情况下发生。
go trace 输出内容含义
gc 252: 这是第252次gc。
@4316.062s: 这次gc的markTermination阶段完成后,距离runtime启动到现在的时间。
0%:当目前为止,gc的标记工作(包括两次mark阶段的STW和并发标记)所用的CPU时间占总CPU的百分比。
0.013+2.9+0.050 ms clock:按顺序分成三部分,0.013表示mark阶段的STW时间(单P的);2.9表示并发标记用的时间(所有P的);0.050表示markTermination阶段的STW时间(单P的)。
0.10+0.23/5.4/12+0.40 ms cpu:按顺序分成三部分,0.10表示整个进程在mark阶段STW停顿时间(0.013 * 8);0.23/5.4/12有三块信息,0.23是mutator assists占用的时间,5.4是dedicated mark workers+fractional mark worker占用的时间,12是idle mark workers占用的时间。这三块时间加起来会接近2.9*8(P的个数);0.40 ms表示整个进程在markTermination阶段STW停顿时间(0.050 * 8)。
16->17->8 MB:按顺序分成三部分,16表示开始mark阶段前的heap_live大小;17表示开始markTermination阶段前的heap_live大小;8表示被标记对象的大小。
17 MB goal:表示下一次触发GC的内存占用阀值是17MB,等于8MB * 2,向上取整。
8 P:本次gc共有多少个P。