每天,Pusher实时发送数十亿条消息:从消息源到达目的地控制在100ms内。 我们如何实现这一目标? 一个关键因素是Go的低延迟垃圾回收器。

垃圾收集器是实时系统的祸根,因为他们会暂停程序。 因此,在设计我们的新消息总线时,我们仔细选择了语言。 虽然Go强调低延迟垃圾回收,但我们很警惕:Go真的做到这一点吗? 如果能做到,效果如何呢?

在这篇博文中,我们会审视Go的垃圾收集器。 我们将看看它是如何工作的(三色算法),为什么它有这样短的GC暂停,最重要的是,它是否工作(对其进行GC基准测试,并与其他语言进行比较)。

From Haskell to Go

我们一直在构建的系统是一个带有已发布消息内存存储的pub / sub消息总线。 Go中的这个版本是Haskell版本的重新实现。在发现GHC的垃圾收集器的延迟问题后,我们在5月停止了在Haskell版本的工作。

我们发布了Haskell版本的细节。基本问题是GHC的暂停时间与工作集的大小(即内存中的对象数量)成正比。在我们的例子中,我们在内存中有很多对象,这导致了几百毫秒的暂停时间。任何GC在完成收集之前都会阻塞程序。

Go与GHC的STW(stop-the-world)收集器不同,Go的垃圾回收器与程序同时运行,这使得避免更长的停顿时间成为可能。我们对Go的低延迟垃圾回收感到鼓舞,并发现随着版本改进延迟得到进一步降低。

并发垃圾收集器如何工作?

Go的GC如何实现并发?其 核心是三色标记扫描算法。 它使GC与程序同时运行; 这意味着暂停时间成为调度问题。 调度程序可以配置为仅在短时间内运行GC收集,与程序交叉运行。 这对我们的低延迟要求是个好消息!

GC仍然有两个暂停阶段:对根对象的初始堆栈扫描,以及标记终止阶段。 令人兴奋的是,这个终止阶段最近已经消除。 我们将在后面讨论这个优化。 在实践中,我们发现即使具有非常大的堆,这些阶段的暂停时间也可以<1ms。

使用并发GC,也有可能在多个处理器上并行运行GC。

延迟VS吞吐量

如果使用并发GC可以在大堆上得到低得多的延迟,为什么要使用stop-the-world收集器?是不是Go的并发垃圾收集器比GHC的stop-the-world收集器更好吗?

不必要。低延迟有成本。最重要的成本是减少吞吐量。并发性需要额外的工作来同步和复制,这会减少程序正常运行的时间。 GHC的垃圾收集器针对吞吐量进行了优化,但Go收集器对延迟进行优化。在Pusher,我们关心延迟,所以这是一个对我们来说很好的折衷。

并发垃圾收集器的第二个成本是不可预测的堆增长。程序可以在GC运行时分配任意数量的内存。这意味着GC必须在堆达到目标最大大小之前运行。但是如果GC运行得太快,那么将执行更多的收集工作。这种权衡是棘手的(Austin Clements提供了一个很好的概述)。在Pusher,这种不可预测性不是一个问题;我们的程序倾向于以可预测的恒定速率分配内存。

在实践中如何?

到目前为止,Go的GC看起来很适合我们的延迟要求。 但它在实践中如何?

今年早些时候,当调查Haskell实现的暂停时间时,我们为测量暂停创建了一个基准。 基准程序重复地将消息推送到大小受限的缓冲区中。 旧消息不断地过期并变成垃圾。 堆大小保持很大,这很重要,因为必须遍历堆才能检测哪些对象仍被引用。 这就是为什么GC运行时间与它们之间的活对象/指针的数量成正比。

这里是Go中的基准,其中缓冲区被建模为数组:

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,以便比较它的效果。
不用多说,这里是我的系统上的基准测试结果:

在这里是Java表现很差,OCaml表现非常好。 OCaml的~3ms暂停时间是由于OCaml用旧一代的增量GC算法。 (我们不选择OCaml的主要原因是它的并发支持很差)。

如你所见,Go执行顺利,暂停时间约为7ms。 这达到我们的要求。

一些注意事项

警惕基准!不同的运行时针对不同的用例和不同的平台进行了优化。然而,由于我们有明确的延迟要求,并且这个基准代表我们的用例,它表明Go对我们来说很好。

map vs array - 最初我们的基准是基于从map中插入和删除项目。然而,Go的垃圾收集器在处理大map的时候有bug,这掩盖了我们的结果。为此,我们决定切换为可变数组的map。Go Map bug在Go 1.8中已经修复,但是并不是所有的基准都被移植到1.8,这就是为什么我要区分这两者。尽管如此,没有理由期望GC时间比map(除了错误或不良实现)更糟糕。

手动vs rts计时 - 作为第二个警告,基准在计时方面不同:一些基准使用手动计时器,但其他使用运行时系统统计。存在此差异,因为某些运行时不会使该统计信息可用(例如在Go中)。我们还担心,打开profiling会对影响一些语言的垃圾收集器。为此,我们将所有基准移植到手动计时。

最后一个警告是基准实现中的最坏情况。有一种情况, insert/delete map操作可能不利地影响定时,这是切换到使用简单数组的另一个原因。

请为我们的基准贡献更多的语言!这个简单的基准是非常通用的,在选择语言时是一个重要的基准。你想看看$ YOUR_LANGUAGE的GC执行情况,然后请提交PR! :)我会特别感兴趣的是知道为什么Java暂停时间是如此糟糕,因为按理论它应该更好。

为什么Go的结果不好?

使用已修复mapbug的编译器,或使用数组时,我们得到暂停时间~7ms。 这是非常好的,但是根据Go团队在演示幻灯片标题为“1.5 Garbage Benchmark Latency”的基准测试结果,我们预计我们的堆大小为200MB时暂停大约1m(GC次数往往与 指针数量而不是字节数,但是它们不能提供该信息)。 Twitch团队使用Go1.7达到约1ms的暂停时间(尽管它们不清楚堆对象的数量)。

我在golang-nuts邮件列表问这个原因。 Rhys Hilter的想法是,这些暂停时间可能是由这个当前未定的错误引起的,GC中的空闲标记worker可能会阻止程序,即使有工作要做。 为了尝试并确认这一点,我启动了go tool trace [3],它可视化程序运行时行为。
从这个例子可以看出,有一个12ms的周期,其中背景标记woker正在所有四个处理器上运行,阻塞程序。这使我强烈怀疑我遇到上述错误。

到目前为止,我很高兴看到基准测试的现有暂停时间满足要求,但我也保持关注,以解决上述问题。

如前所述,Go团队最近宣布了一项改进措施,导致GC暂停时间小于1ms。它燃起我的希望,但我很快就意识到这个优化是去掉了stw阶段,swt阶段在我使用的基准下已经<1ms。我们的暂停时间主要是由GC的并发阶段引起的。

尽管如此,这是一个值得欢迎的改进,并表明团队继续关注并改进GC的延迟。这种优化的技术描述本身是一个有趣的读物。

结论

这项调查的关键是GC是针对更低的延迟或更高的吞吐量进行优化。程序可能执行更好或更差在这取决于您的程序的堆使用率。 (有很多的对象吗?他们有长或短的生命吗?)

重要的是要了解底层的GC算法,以决定它是否适合您的用例。在实践中测试GC实现也很重要。您的基准测试应该与您打算实现的程序具有相同的堆使用率。这将在实践中验证GC实现的有效性。正如我们所看到的,Go的实现不是没有bug的,但在我们的情况下,问题是可以接受的。我想在更多的语言中看到相同的基准,如果你想贡献的话:)

尽管存在一些问题,Go的GC与其他GC语言相比表现良好。 Go团队一直在改进延迟,并继续这样做。我们对Go语言GC的理论和实践感到满意。

GIAC大会

介绍一个跟编程密切相关的一个技术活动,GIAC 全球互联网架构大会将在 12 月 16 ~ 17 日在北京举行。
参加 GIAC,认识更多技术牛人,最后一周优惠,购买双日套票,高可用架构后花园会员最低仅需 900 元,非会员最低只需 1,260 元,点击阅读原文进入购买页面。