美文网首页
Golang的GC理论与实战

Golang的GC理论与实战

作者: 雨生_ | 来源:发表于2019-03-11 15:15 被阅读13次

    Golang的GC理论与实战

    每天,推送端会实时推送数十亿的数据,但是我们的服务端延迟时间却不足100ms,我们如何做到这一点的呢? 一个关键的因素就是Go的低延迟垃圾回收器。

    迟垃圾回收器最讨厌的地方,就是会实时暂停程序。所以当我们设计消息总线的时候,我们需要仔细的选择编程语言,Go强调它的低延迟,但是我们不确定它是否像说的那样,如果是的话,又是怎么样的情况呢?

    所以这篇博客的目的,就是让我们一起来看一下Go的垃圾回收期。我们将会看一下他是如何工作的(三色回收器), 为什么可以提供如此短暂的GC暂停时间,和最重要的,他是否像说的那样工作,并对比一下其他的编程语言的GC垃圾回收期。

    Haskell到Go

    我们需要构建一个发布/订阅消息总线的系统,包括发送端消息的存储。我们用Go语言实现了第一版(之前是Haskell形式), 在发现GHC垃圾收集器的基本延迟问题之后,我们在5月份停止了Haskell版本的开发。

    对此,我们发布了一个对于Haskell的详细分析报告。最基本的问题是GHC暂停的时间与内存分配的大小成正比。在我们的例子中,我们的内存中有许多对象,这导致了数百毫秒的暂停时间。对于大多数GC收集器,这都是一个比较普遍的问题。

    而对于Go来说,并不像GHC这样的STW收集器,他的收集器是跟程序并行的方式,这样避免了较长的暂停时间。Go致力于降低暂停时间,每个版本的细微调整对我们来说都是很振奋人心的事情。

    并发收集器是如何工作的

    Go是如何做到并发收集的呢?根本的思想是三色标记扫描算法。下面的一组图片,展示了他的执行内容(原文是一组动画,我改成图片展示了)。需要注意的是,他是如何与程序并行工作的,这意味着暂停的时间变成了一个调度的问题,可以通过调度程序配置并与程序交叉运行,来实现短的暂停时间。这对于低延迟的需求来说,是一个非常好的消息。

    执行的代码:

    var A LinkedListNode;
    var B LinkedListNode;
    // ...
    B.next = &LinkedListNode{next: nil};
    // ...
    A.next = &LinkedListNode{next: nil};
    *(B.next).next = &LinkedListNode{next: nil};
    B.next = *(B.next).next;
    B.next = nil;
    
    1. 当程序执行一段时间之后:
      程序在操作链表,创建了ABC三个节点,红色的AB为根节点,他们总是可以被访问。B节点有一个子节点C(B.next=&C),收集器将所有的对象分为三类颜色,黑色,灰色和白色。现在,由于垃圾回收器没有执行,所以三个对象都在白色区域。


      01-程序执行.PNG
    2. 当程序将D的地址分配给A.next 的时候,注意D是一个新的对象,D会被放到灰色区域。这里有一条规则:因为D被分配到A.next,所以它被标记为灰色。当一个指针字段被更改的时候,指向的对象会被着色。所有的新对象被指向到某一个地方的时候,他们就会迅速的被着色为灰色。 02-程序执行.PNG
    1. GC开始执行
      在GC开始执行的时候,跟节点会被放置到灰色区域,本次模拟中,根AB和已经在灰色区域的D,都被标记为灰色。由于任何执行的步骤不是程序执行就是GC执行,所以接下来将会看到程序和GC交错执行的过程。


      03-GC执行.PNG
    2. GC开始执行,讲A放到要扫描的区域中。
      如果需要扫描一个对象,就需要把它放到黑色区域,并将它的子对象设置为灰色。A有一个子对象D,并且已经处在D区域了。任何的步骤,我们都可以计算出来他要执行的步骤,公式是 2 * (White) + (Grey), 收集器在每个步骤里面,至少要移动一次,直到为0时,就算完成了。


      04-GC执行.PNG
    3. 程序创建了一个新对象E
      程序创建了一个新对象E,分配给C.next, E会直接被放到灰色区域。这时候,收集器增加了需要执行的步骤数。分配大量对象的过程,程序会进行延迟扫描。需要注意的是,这时候白色区域的大小会逐渐减少,当再次扫描堆的时候,才会被再填充。


      05-程序执行.PNG
    4. 程序移动了指针
      当程序执行B.next = *(B.next).next 的时候,对象C变成了不可达对象(没有能指向对象C的指针),这意味着收集器要把C留在白色区域,并在GC执行结束的时候,回收掉它。


      06-程序执行.PNG
    5. GC 选中要扫描的对象D
      D没有后代需要带到灰色区域。


      07-GC执行.PNG
    6. 程序将B.next = nil

      E变成不可达对象,由于E在灰色区域,所以他不会被回收。但是这并不意味这内存泄露,因为E会在下次垃圾回收的时候,被回收掉。三色标记法的原则是,如果一个对象在GC周期开始的时候不可达,才会在这个周期内被回收掉。 08-程序执行.PNG
    1. GC 选中要扫描的对象E
      E被放到黑色区域。注意,这里尽管C指向E,但是C并没有移动,因为指针发起者不是E(没有指向C的指针)


      09-GC执行.PNG
    2. GC 选中最后一个要扫描的对象B
      这之后灰色区域为空了。


      10-GC执行.PNG
    3. 收集器释放白色的集合
      这个时候,灰色集合为空,所有在白色区域的对象都是不可达的(目前只有C), 收集器释放白色区域的所有对象,尽管E不可达,但是他在下次GC周期的时候,会被释放,原因是他在这次GC的周期内,不可达。


      11-GC执行.PNG
    4. 收集器重新分配颜色,一次GC周期完成
      在实现的代码里面,收集器不需要移动或者重新给每个对象分配颜色。下次回收的时候,回收黑色的对象即可(这次白色意味着不可达对象,下次就是黑色), 这样的方式更快,更方便。


      12-GC执行.PNG

    上面的图片,详细的介绍了标记的全过程,GC目前仍有两个STW的暂停时间点,分别是扫描的开始和结束的时候。不过令人兴奋的是,终止截断目前被去除掉了。后, 我们在讨论这个优化的细节。在具体的测试中,我们发现,对于非常大的堆,暂停时间仍然小于1ms。
    对于并发的GC,我们还可以在多核系统上并行执行GC。

    延迟 VS 吞吐量

    对于大的堆内存,如果延迟很低的话,那为什么还要使用STW的收集器呢? Go的并发收集器比GHC的STW更好么。

    其实并不是这样,低延迟也是有代价的。最重要的成本就是降低吞吐量。并发的过程需要一些额外的同步和复制的工作。这个操作会占用程序执行他自身的工作的时间。GHC的垃圾回收期针对吞吐量进行了优化。而Go的垃圾回收期,针对的是低延迟进行的优化。对于消息的推送而言,我们更关心的肯定是低延迟,所以这对于我们来说,是一个比较好的平衡。

    并发收集器第二个代价就是不可预期的堆增长。程序在GC运行时可以动态的分配任意数量的内存。这意味着必须在堆达到内存极限之前执行GC(堆内存满了,动态GC的时候没有内存可以分配,将无法释放内存), 但如果GC的频率太高,则会执行很多不必要的操作,浪费性能。两者的权衡很麻烦(参考文献)。不过在我们的程序里面(Pusher),这种不可预测的问题对我们来说并不是问题,我们的程序更倾向于可预测的恒定速率的分配内存。

    实战效果

    目前为止,Go的GC看起来是非常适合我们低延迟的要求,但是他实战中运行效果怎么样呢?
    今年早些时候,在研究Haskell暂停时间的时候,我们创建了一个衡量暂停时间的标准测试,测试内容是反复推送大小有限的缓冲区,这样旧的消息就会成为不断地过期并成为垃圾,并且堆内存始终保持最大。这样的话,必须要遍历整个堆才能知道那些对象可达。这就是GC运行时间与 他们 活跃对象/指针数量 成正比的原因。

    这里是测试的代码,缓冲区用数组代替。

    package main
    
    import (
        "fmt"
        "time"
    )
    
    const (
        windowSize = 200000
        msgCount   = 1000000
    )
    
    type (
        message []byte
        buffer  [windowSize]message
    )
    
    var worst time.Duration
    
    func mkMessage(n int) message {
        m := make(message, 1024)
        for i := range m {
            m[i] = byte(n)
        }
        return m
    }
    
    func pushMsg(b *buffer, highID int) {
        start := time.Now()
        m := mkMessage(highID)
        (*b)[highID%windowSize] = m
        elapsed := time.Since(start)
        if elapsed > worst {
            worst = elapsed
        }
    }
    
    func main() {
        var b buffer
        for i := 0; i < msgCount; i++ {
            pushMsg(&b, i)
        }
        fmt.Println("Worst push time: ", worst)
    }
    

    在James Fisher的博客之后,Gabriel Scherer也写了一个后续的博客,将原有的Haskell基准测试与Ocaml和Racket进行了比较,他创建了包括这些基准本事的报告,Santeri Hiltunen 也添加了一个Java版本的测试。我决定创建一个Go版本的测试,来看看他们之间的比较结果。

    Benchmark Longest pause (ms)
    OCaml 4.03.0 (map based) (manual timing) 2.21
    Haskell/GHC 8.0.1 (map based) (rts timing) 67.00
    Haskell/GHC 8.0.1 (array based) (rts timing) 1 58.60
    Racket 6.6 experimental incremental GC (map based) (tuned) (rts timing) 144.21
    Racket 6.6 experimental incremental GC (map based) (untuned) (rts timing) 124.14
    Racket 6.6 (map based) (tuned) (rts timing) 113.52
    Racket 6.6 (map based) (untuned) (rts timing) 136.76
    Go 1.7.3 (array based) (manual timing) 7.01
    Go 1.7.3 (map based) (manual timing) 37.67
    Go HEAD (map based) (manual timing) 7.81
    Java 1.8.0_102 (map based) (rts timing) 161.55
    Java 1.8.0_102 G1 GC (map based) (rts timing) 153.89

    这里有两个比较令人惊讶的地方,第一个就是Java的性能如此的差,另一个是Ocaml的性能非常好,只有接近3ms的暂停是,这是由于他使用了老一代的GC增量算法,但我们不使用它的原因是因为他对多核CPU的支持很差。

    正如你看到的,Go的表现还不错,接近7ms的暂停时间,足够支撑我们的需求。

    一些警告

    不要太过于依赖这种测试,不同的运行时针对的是不同的平台和用例。因为我们有明确的需求(低延迟), 并且这个测试的结果正好符合我们的预期,所以Go对于我们来说,非常有用。

    字典 vs 数组 : 最开始的时候,我们的测试是基于map进行大量的插入和删除操作。但是Go对于数目庞大的map有一个bug,这个bug使得我们的测试结果可能变得不准确。所以我们换成了可变数据。关于这块的讨论,可以看这篇文章。这个map的bug在Go1.8的版本已经修复了,但是我们并没有更新我们所有的测试内容。所以以上的测试包括了map和array两个版本。但是两组测试可以发现,几乎没有太大的差异,所以只需要注意map是否会带来bug就好了。

    手动 vs 自动统计 时间:第二个警告是,基准测试的时间统计有所差异,有些是手工统计的,有的是利用运行时机制统计的。我们对于系统统计对于GC是否同样也有影响表示怀疑,所以我们建议基准测试都采用手工统计的方式。(译者:真是精益求精啊!!!)

    最后一个警告就是,对于map,存在最坏情况的插入删除时间,在这种情况下,会对测试结果产生较大的影响,这也是为什么我们还使用简单数组在测试的原因。

    我们非常欢迎阅读的你,为更多的编程语言提供测试。如果你希望查看某一个编程语言的GC性能,欢迎你提交一个PR,同样的我们也很想知道为什么Java的性能如此的糟糕(理论上不应该如此)

    为什么Go的结果不是最好的

    很多人用1.8以上的版本进行测试(map bug fix之后),有的是用array进行测试,得到的结果就是暂停时间在7ms左右,这已经是一个比较不错的结果了。但是Go 团队对于这个测试的结果,期望的结果是200MB的堆内存,暂停时间是1ms, Twitch团队还用Go1.7版本,存在大约1ms左右的暂停时间,但未公布他们测试的堆内存数量。

    我曾给Golang-nuts 组发邮件,提过这个问题。Rhys Hilter 回复说,这可能是由于未知的bug造成的。GC进行空闲标记的时候,无论有没有工作做,都会阻塞程序。为了验证这个想法,我们通过go tool trace 的可视化工具,进行了测试。

    go-tool-trace-gc-pause.png

    通过图片不难看出,中间有一段12ms的暂停,四个处理器都在处理标记工作,阻塞了程序,这让我强烈的怀疑,我们遇到了未曾解决的bug。

    目前为止,我们队基准测试的暂停时间还是比较满意的,同时我们也在关注上面提的那个问题。

    正如上面提到的,最近Go团队发布了一些优化的消息,声称优化可以做到把暂停时间降到1ms以下。这让我们燃起了希望,但很快我意识到,GC已经移除了一个GC的暂停时间(结束的时候), 在我们的测试中,已经达到了1ms以下。那暂停时间的问题上,应该是由于并发过程中导致的。

    尽管如此,这仍然是一个让人振奋的消息,并且GC团队会持续跟进GC的延迟问题,技术上的优化细节建议各位读一下,挺有趣的。

    结论

    GC优化无非就两个方面,降低延迟和保证高的吞吐量,性能的表现取决于堆内存的使用情况(更多的内存对象还是更多'短命'的对象)

    一个GC的算法是否适合与你,了解他的底层原理很重要,在实践中测试GC的性能也同样重要。基准测试的测试结果要尽可能的模仿你程序的真是运行结果。而且正如我们所看到的,Go也存在缺陷(Bug & 降低了吞吐量), 只不过在我们的使用场景下,可以接受。

    尽快存在一些问题,Go的GC算法与其他具备GC能力的语言相比,还是不错的。并且Go的团队也一直在不断的降低延迟。无论理论与实践结果,我们对GC的性能还是比较满意地。

    作者

    • James Fisher for the animation.
    • Gabriel Scherer and Santeri Hiltunen for benchmarks.

    翻译

    原文链接:Golang’s Real-time GC in Theory and Practice

    译者:JYSDeveloper

    相关文章

      网友评论

          本文标题:Golang的GC理论与实战

          本文链接:https://www.haomeiwen.com/subject/aekbpqtx.html