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;
-
当程序执行一段时间之后:
程序在操作链表,创建了ABC三个节点,红色的AB为根节点,他们总是可以被访问。B节点有一个子节点C(B.next=&C),收集器将所有的对象分为三类颜色,黑色,灰色和白色。现在,由于垃圾回收器没有执行,所以三个对象都在白色区域。
01-程序执行.PNG - 当程序将D的地址分配给A.next 的时候,注意D是一个新的对象,D会被放到灰色区域。这里有一条规则:因为D被分配到A.next,所以它被标记为灰色。当一个指针字段被更改的时候,指向的对象会被着色。所有的新对象被指向到某一个地方的时候,他们就会迅速的被着色为灰色。 02-程序执行.PNG
-
GC开始执行
在GC开始执行的时候,跟节点会被放置到灰色区域,本次模拟中,根AB和已经在灰色区域的D,都被标记为灰色。由于任何执行的步骤不是程序执行就是GC执行,所以接下来将会看到程序和GC交错执行的过程。
03-GC执行.PNG -
GC开始执行,讲A放到要扫描的区域中。
如果需要扫描一个对象,就需要把它放到黑色区域,并将它的子对象设置为灰色。A有一个子对象D,并且已经处在D区域了。任何的步骤,我们都可以计算出来他要执行的步骤,公式是 2 * (White) + (Grey), 收集器在每个步骤里面,至少要移动一次,直到为0时,就算完成了。
04-GC执行.PNG -
程序创建了一个新对象E
程序创建了一个新对象E,分配给C.next, E会直接被放到灰色区域。这时候,收集器增加了需要执行的步骤数。分配大量对象的过程,程序会进行延迟扫描。需要注意的是,这时候白色区域的大小会逐渐减少,当再次扫描堆的时候,才会被再填充。
05-程序执行.PNG -
程序移动了指针
当程序执行B.next = *(B.next).next 的时候,对象C变成了不可达对象(没有能指向对象C的指针),这意味着收集器要把C留在白色区域,并在GC执行结束的时候,回收掉它。
06-程序执行.PNG -
GC 选中要扫描的对象D
D没有后代需要带到灰色区域。
07-GC执行.PNG -
程序将B.next = nil
E变成不可达对象,由于E在灰色区域,所以他不会被回收。但是这并不意味这内存泄露,因为E会在下次垃圾回收的时候,被回收掉。三色标记法的原则是,如果一个对象在GC周期开始的时候不可达,才会在这个周期内被回收掉。 08-程序执行.PNG
-
GC 选中要扫描的对象E
E被放到黑色区域。注意,这里尽管C指向E,但是C并没有移动,因为指针发起者不是E(没有指向C的指针)
09-GC执行.PNG -
GC 选中最后一个要扫描的对象B
这之后灰色区域为空了。
10-GC执行.PNG -
收集器释放白色的集合
这个时候,灰色集合为空,所有在白色区域的对象都是不可达的(目前只有C), 收集器释放白色区域的所有对象,尽管E不可达,但是他在下次GC周期的时候,会被释放,原因是他在这次GC的周期内,不可达。
11-GC执行.PNG -
收集器重新分配颜色,一次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
的可视化工具,进行了测试。
通过图片不难看出,中间有一段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
网友评论