Golang实时GC的理论与实践总结

本文是总结Go语言的低延迟垃圾回收机制GC突出之处。

Pusher是一个简单的托管API,通过WebSockets集成到网络和移动应用程序或任何其他互联网连接的设备上,实现快速,轻松,安全地将实时双向功能。每天,Pusher实时发送数十亿条消息:从源发送信息到目标只需要100ms。我们如何实现这一目标? 一个关键因素是Go的低延迟垃圾回收器。

垃圾收集器是实时系统的缓慢的元凶,因为他们会暂停程序运行。所以在设计我们的新消息总线时,我们仔细选择了语言。 Go注重低延迟 ,但我们很谨慎:Go真正实现这一目标? 如果是,如何?

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

从Haskell到Go

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

GHC的暂停时间与工作集的大小(即内存中的对象数量)成正比。在我们的例子中,我们在内存中有很多对象,这导致了几百毫秒的暂停时间。 这是一个问题,任何GC当它完成收集时会阻塞程序。

进入Go以后,它不像GHC的停止世界收集器,Go的收集器与程序同时运行,这使得避免更长的停顿成为可能。 我们被Go注重低延迟特性鼓励,并发现它很有前途,特别是当我们读到其每一个新版本都有关改善延迟时。

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

Go的GC如何实现这种并发? 其核心是三色标记-清除(tricolor mark-and-sweep)算法 。下面是一个动画(见原文动画),显示了算法的工作原理。 注意它让GC与程序同时运行;


这意味着暂停时间成为调度问题。 调度程序可以配置为仅在短时间内运行GC集合,与程序交织。 这对我们的低延迟要求是个好消息!


上面的动画详细显示了标记阶段。 GC仍然有两个停止世界阶段:对根对象的初始堆栈扫描,以及标记阶段的终止。 令人兴奋的是,这种终止阶段,最近被淘汰 。 我们将在后面讨论这个优化。 在实践中,我们发现对非常大的堆这些阶段的暂停时间为<1ms,。

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


延迟与吞吐量

如果并发GC针对很大的堆产生很低的延迟,为什么要使用stop-the-world收集器?是不是Go的并发垃圾收集器比GHC的stop-the-world的收集器更好 ?

不一定。 低延迟是要付出成本的。 最重要的成本降低吞吐量 。并发性需要额外的工作来同步和复制,这使得程序可以做有用的工作。GHC的垃圾收集器针对吞吐量进行了优化,但Go针对了延迟优化。 在Pusher,我们关心延迟,所以对于我们这是一个很好的方案。

并发垃圾回收的第二个成本是不可预测的堆增长。程序可以在GC运行时分配任意数量的内存。这意味着GC必须在堆达到目标最大之前运行。 但是如果GC运行得太快,那么将执行更多的收集。 这个代价是非常棘手。在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)
}

结合其它人关于Ocaml Racket 和Java的基准测试,下面是在我系统上的测试结果,右边是最长暂停时间ms:


基准 最长暂停 (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) 144.21
(tuned) (rts timing)
Racket 6.6 experimental incremental GC (map based) 124.14
(untuned) (rts timing)
Racket 6.6 (map based) (tuned) (rts timing) [2] 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表现非常好,不到〜3毫秒暂停时间,是由于为老一代使用增量的GC算法。 (我们不选择OCaml的主要原因是它支持多核并行性不好)。

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

..

结论

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

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

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