译文 [译] Golang 实时垃圾回收理论和实践

astaxie · 2016年12月07日 · 最后由 xiemengjun 回复于 2020年02月11日 · 925 次阅读
本帖已被设为精华帖!

Golang 实时垃圾回收理论和实践

每天,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 (
        &quot;fmt&quot;
        &quot;time&quot;
)

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 &gt; worst {
                worst = elapsed
        }
}

func main() {
        var b buffer
        for i := 0; i &lt; msgCount; i++ {
                pushMsg(&amp;b, i)
        }
        fmt.Println(&quot;Worst push time: &quot;, worst)
}

根据 James Fisher 的博客,Gabriel Scherer 写了一篇后续博客文章,将原来的 Haskell 基准与 OCaml 和 Racket 的版本进行比较。 他创建了一个包含这些基准的仓库,Santeri Hiltunen 添加了一个Java 版本。 我决定将基准移植到 Go,以便比较它的效果。 gc对比 不用多说,这里是我的系统上的基准测试结果:

在这里是 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],它可视化程序运行时行为。 gc可视化工具 从这个例子可以看出,有一个 12ms 的周期,其中背景标记 woker 正在所有四个处理器上运行,阻塞程序。这使我强烈怀疑我遇到上述错误。

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

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

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

结论

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

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

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

原文:https://blog.pusher.com/golangs-real-time-gc-in-theory-and-practice/

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
xiemengjun 将本帖设为了精华贴 02月11日 01:48
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册