[译]Go:垃圾回收器是如何监控你的应用的?

原文: medium.com/a-journey-w…

本文基于Go 1.13

Go的垃圾回收器旨在帮助开发者自动清理应用程序的内存。然而每次跟踪内存并清理都会影响程序运行的性能。Go的垃圾回收器旨在清理内存的同时也关注性能,主要是以下几个指标:

  • 当程序暂停的时的两阶段尽可能减少 (这句我也不太知道怎么翻)
  • 一次垃圾回收的周期少于10ms
  • 一次垃圾回收操作不能占用超过25%的CPU

这看上去是一个很难实现的目标,本篇文章就是介绍Go是如何完成这些目标的。

堆阈值 Heap Threshold Reached

垃圾回收器关注的第一个指标就是堆的增长。默认情况下,当堆的大小变成原来的两倍的时候,垃圾回收器会被启动。这里有个例子,在循环里面不断分配内存

func BenchmarkAllocationEveryMs(b *testing.B) {
    // 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)
}
复制代码

追踪曲线告诉我们,垃圾回收器被触发

当堆的大小变成原来两倍的时候,内存分配者会触发垃圾回收器。这个也可以通过增加参数 GODEBUG=gctrace=1 来将整个生命周期的性能打印出来

gc 8 @0.251s 0%: 0.004+0.11+0.003 ms clock, 0.036+0/0.10/0.15+0.028 ms cpu, 16->16->8 MB, 17 MB goal, 8 P

gc 9 @0.389s 0%: 0.005+0.11+0.007 ms clock, 0.041+0/0.090/0.11+0.062 ms cpu, 16->16->8 MB, 17 MB goal, 8 P

gc 10 @0.526s 0%: 0.046+0.24+0.014 ms clock, 0.37+0/0.14/0.23+0.11 ms cpu, 16->16->8 MB, 17 MB goal, 8 P
复制代码

周期9是我们之前看到的运行时间为389ms的周期。有趣的是这部分: 16->16->8 MB , 展示了在垃圾回收之前有多少内存正被占用以及垃圾回收之后剩余的内存量。我们清楚地看到,当周期8将堆减少到8MB时,周期9已在16MB处触发。

这个阈值通过环境变量GOGC来设置,默认是100%,也就是当堆的大小增加100%时垃圾回收器会被触发。从性能原因考虑,也为了避免不断地开始新的垃圾回收,所以当堆的大小小于4MB*GOGC的时候,尽管GOGC设成100%,但垃圾回收依然不会被触发

时间阈值 Time Threshold Reached

第二个垃圾回收器关注的之间是两次垃圾回收时间之间的间隔,如果大于2分钟,就会强制执行垃圾回收。

这个能根据给定 GODEBUG 参数看到,程序在两分钟之后执行了强制的垃圾回收

GC forced
gc 15 @121.340s 0%: 0.058+1.2+0.015 ms clock, 0.46+0/2.0/4.1+0.12 ms cpu, 1->1->1 MB, 4 MB goal, 8 P
复制代码

协助 Required Assistance

垃圾回收器由两部分组成

  • 标记内存仍然在使用
  • 将没有标记正在使用的内存进行替换

在标记阶段,Go必须确保标记内存的速度比分配新内存的速度更快。 实际上,如果收集器标记了4Mb的内存,而在同一时间段内程序分配了相同数量的内存,则垃圾收集器必须在完成后立即触发。

为了解除这个问题,Go在标记内存的同时跟踪新的内存分配,并且会去查看垃圾回收器什么时候需要被触发。当垃圾回收触发时第一步开始,他将首先准备给每个processor(GMP中的P)一个goroutine,这个gourtine最开始是处理休眠状态的,等待标记阶段的进行。

跟踪可以显示这些goroutines

一旦这些goroutinues产生以后,垃圾回收器会开始进行标记,会去检查哪个变量是需要被收集以及替换的。标记为 GC dedicated 的goroutines在没有抢占的情况下才会进行标记操作,而标记为GC空闲的goroutine则在可以直接进行标记操作,因为它们没有其他任何需要运行的东西,可以被抢占。

垃圾回收器现在可以准备将变量标记为不再使用了。对于每一个变量扫描,都会增加一个counter为了跟踪当前工作还有多少剩余的工作需要被进行。当在垃圾收集期间安排goroutine工作时,Go会将所需的内存分配与已经完成的扫描进行比较,以便比较扫描的速度和分配的要求。如果扫描的速度能比分配的速度快则不需要额外的协助,相反,如果扫描的速度比内存分配的速度要慢,Go会启动额外的goroutine来协助标记工作。这个图反应了这个逻辑:

在我们的例子中,goroutine 14 被唤起工作当扫描速度比分配速度低的时候:

CPU限制 CPU limitation

其中一个垃圾回收器的指标是不能占用超过CPU的25%。这意味着Go在标记阶段不能分配多于四分之一的处理器。实际上,这正是我们在前面的示例中看到的,只有两个goroutines超出了处理器的高度,完全专用于垃圾收集:

我们可以看到,另一个goroutine在他没有其他工作的时候会为标记进行工作。然而,当垃圾回收器发出协助请求的时候,Go会在高峰期时超过25%的CPU占用,如我们所见goroutinue 14

在我们的示例中,在短时间内,将37.5%的处理器(八分之三)分配给标记阶段。 但这种情况可能很少见,只有在内存高分配的情况下才会发生。