.NET GC 暂停时间分析
过去的几个月里面,有几篇博客文章讨论了各个编程语言或者运行时机制GC的暂停时间。一切的起源都是源于一片研究Haskell GC延迟 的文章,之后又发布了一篇Haskell,OCaml 和 Racket 对比,之后就是Go GC的理论与实践, 最后还有一篇Erlang 的GC情况
在阅读了上面的文章之后,我突然想看一下 .NET GC与他们的对比情况。
上面的文章都使用了几乎相同的一个测试程序来查看GC的情况,测试程序是基于一个消息总线的实现场景编写的(具体信息)。近期Franck Jeannin 开始编写.NET版本的测试程序,我们这篇文章就是基于这个进行讨论的。
相关的测试代码:
for (var i = 0; i < msgCount; i++)
{
var sw = Stopwatch.StartNew();
pushMessage(array, i);
sw.Stop();
if (sw.Elapsed > worst)
{
worst = sw.Elapsed;
}
}
private static unsafe void pushMessage(byte[][] array, int id)
{
array[id % windowSize] = createMessage(id);
}
所有的实现代码
这里我们创建了一个"message",这个message实际上就是一个byte[1024]的数组,然后把它放进了一个byte[][]的数据结构里面。这个过程被重复执行了1千万次(msgCount控制),但是array里面最多只能存20万个数据(windowSize控制),也就是我们每次会覆盖旧的数据。
这里统计了每次添加数据的时间,这个操作的时间理论上是很快的,所以统计的时间大概率会等于GC的暂停时间。当然我们也可以通过PerfView tool 来监控GC的暂停时间,双重监控能让我们的结果更具备权威性。
GC两种模式对比(Workstation 与 Server)
Java的GC有很多可配置项目, 但是 .NET GC的选项就很少了,只有如下的这几项:
- 工作站模式(WorkStation)
- 服务器模式(Server)
- 并发模式或者单线程模式
我们这里只比较工作站模式和服务器模式的差异,为了保证数据的准确性,两个比较情况下,都开启并发模式,而且并发模式的暂停时间也会更短一些。
这里有一篇非常棒的概述文章,描述了GC在并发模式下,工作站和服务器模式的异同, 介绍了两种模式的一些针对性的优化内容:
WorkStation GC 是为桌面应用程序专门设计的,可以最大限度的减少GC中花费的时间。这种模式下,GC发生的频率更加频繁,但是暂停的时间相对更短。而服务器模式针对吞吐量做了优化,暂停时间略长,内存消耗也更大,每次触发的内存条件更高,可以支持服务器处理更多的数据。
因此,工作站模式应该比服务器模式的暂停时间更短,结果证明了这一点,具体的细节可以看下图,这个结果是通过HdrHistogram.NET 统计得出的。
X轴是对数结果,可以通过图片看到工作站模式(WKS)的暂停时间在99.99%的时候开始增加,而服务器模式(SVR)从99.9999%的时候开始增加。
另一种方式可以通过下面的表格分析结果,通过表格可以看出,虽然工作站模式的最大暂停时间比较短,但是频次更多,总体的暂停时间更长。
GC Mode | Max GC Pause | GC Pauses | Total GC Pause Time | Elapsed Time | Peak Working Set (MB) |
---|---|---|---|---|---|
Workstation - 1 | 28.0 | 1,797 | 10,266.2 | 21,688.3 | 550.37 |
Workstation - 2 | 23.2 | 1,796 | 9,756.6 | 21,018.2 | 543.50 |
Workstation - 3 | 19.3 | 1,800 | 9,676.0 | 21,114.6 | 531.24 |
Server - 1 | 104.6 | 7 | 646.4 | 7,062.2 | 2,086.39 |
Server - 2 | 107.2 | 7 | 664.8 | 7,096.6 | 2,092.65 |
Server - 3 | 106.2 | 6 | 558.4 | 7,023.6 | 2,058.12 |
因此如果你关心如何减少最大暂停时间,那么工作站模式肯定是不二选择,但是总体上你需要更多的暂停时间,因此你的程序吞吐量会降低。此外,对于服务器模式,他为每个CPU分配了一个堆,所以容量更大。
很幸运的是,在.NET里面我们可以选择使用哪种模式(译者注:不能选的话还怎么玩), 这里有一篇讲现代垃圾回收机制很棒的文章(译者注:这篇文章我也翻译过,可以看这里) ,里面提到了Go的GC几乎只优化暂停时间。
事实上,Go GC并没有实现更多的新创意和新的研究成果。正如他们所公示的内容,他是一个很直接的并发标记/扫描收集的机制,这种方式还是基于1970年的研究想法。GC唯一不同的是他通过牺牲一些其他合理的性能来优化暂停的时间,但是GC的技术发言和推销材料上,却没有提及他们牺牲了什么内容,让那些不熟悉或者不关心垃圾回收机制的人忘记了这段牺牲的内容,甚至暗示出,Go语言的其他竞争对手(Java等)的垃圾回收很差。
不同数量级的存活对象情况下,最大的暂停时间分布
为了进一步研究这块内容,让我们来看一下最大暂停时间与活跃对象数量的变化情况。回头看一下我们的实例代码,我们仍然是分配10,000,000条信息(msgCount控制),然后通过改变windowSize来控制保存的数量。
可以清晰地看到,暂停时间与存活对象数量是成比例的(线性),为什么是这样的?,让我们通过PerfView帮我们分析一下。
windowSize 为 100,000,时间单位为毫秒
GC Index | Gen | Pause MSec | Gen0 Alloc MB | Peak MB | After MB | Promoted MB | Gen 0 MB | Gen 1 MB | Gen 2 MB | LOH MB |
---|---|---|---|---|---|---|---|---|---|---|
2 | 1N | 39.443 | 1,516.354 | 1,516.354 | 108.647 | 104.831 | 0.000 | 107.200 | 0.031 | 1.415 |
3 | 0N | 38.516 | 1,651.466 | 0.000 | 215.847 | 104.800 | 0.000 | 214.400 | 0.031 | 1.415 |
4 | 1N | 42.732 | 1,693.908 | 1,909.754 | 108.647 | 104.800 | 0.000 | 107.200 | 0.031 | 1.415 |
5 | 0N | 35.067 | 1,701.012 | 1,809.658 | 215.847 | 104.800 | 0.000 | 214.400 | 0.031 | 1.415 |
6 | 1N | 54.424 | 1,727.380 | 1,943.226 | 108.647 | 104.800 | 0.000 | 107.200 | 0.031 | 1.415 |
7 | 0N | 35.208 | 1,603.832 | 1,712.479 | 215.847 | 104.800 | 0.000 | 214.400 | 0.031 | 1.415 |
windowSize 为 400,000,时间单位为毫秒
GC Index | Gen | Pause MSec | Gen0 Alloc MB | Peak MB | After MB | Promoted MB | Gen 0 MB | Gen 1 MB | Gen 2 MB | LOH MB |
---|---|---|---|---|---|---|---|---|---|---|
2 | 0N | 10.319 | 76.170 | 76.170 | 76.133 | 68.983 | 0.000 | 72.318 | 0.000 | 3.815 |
3 | 1N | 47.192 | 666.089 | 0.000 | 708.556 | 419.231 | 0.000 | 704.016 | 0.725 | 3.815 |
4 | 0N | 145.347 | 1,023.369 | 1,731.925 | 868.610 | 419.200 | 0.000 | 864.070 | 0.725 | 3.815 |
5 | 1N | 190.736 | 1,278.314 | 2,146.923 | 433.340 | 419.200 | 0.000 | 428.800 | 0.725 | 3.815 |
6 | 0N | 150.689 | 1,235.161 | 1,668.501 | 862.140 | 419.200 | 0.000 | 857.600 | 0.725 | 3.815 |
7 | 1N | 214.465 | 1,493.290 | 2,355.430 | 433.340 | 419.200 | 0.000 | 428.800 | 0.725 | 3.815 |
8 | 0N | 148.816 | 1,055.470 | 1,488.810 | 862.140 | 419.200 | 0.000 | 857.600 | 0.725 | 3.815 |
9 | 1N | 225.881 | 1,543.345 | 2,405.485 | 433.340 | 419.200 | 0.000 | 428.800 | 0.725 | 3.815 |
10 | 0N | 148.292 | 1,077.176 | 1,510.516 | 862.140 | 419.200 | 0.000 | 857.600 | 0.725 | 3.815 |
11 | 1N | 225.917 | 1,610.319 | 2,472.459 | 433.340 | 419.200 | 0.000 | 428.800 | 0.725 | 3.815 |
额外堆
最后,如果你想在.NET里面彻底消除暂停时间,可以使用额外堆,通过unsafe
的标记来实现:
var dest = array[id % windowSize];
IntPtr unmanagedPointer = Marshal.AllocHGlobal(dest.Length);
byte* bytePtr = (byte *) unmanagedPointer;
// Get the raw data into the bytePtr (byte *)
// in reality this would come from elsewhere, e.g. a network packet
// but for the test we'll just cheat and populate it in a loop
for (int i = 0; i < dest.Length; ++i)
{
*(bytePtr + i) = (byte)id;
}
// Copy the unmanaged byte array (byte*) into the managed one (byte[])
Marshal.Copy(unmanagedPointer, dest, 0, dest.Length);
Marshal.FreeHGlobal(unmanagedPointer);
注意:我不建议使用这种方式,除非你分析了是GC暂停时间的问题,他被成为不安全的方式是有原因的。
结果显而易见,是有效的。但是也没什么可奇怪的,我们无法管理GC,所以也没有GC的暂停时间。
在结束之前,我们再做最后看一下Maoni Stephens做的一些测试,Maoni Stepthens是.NET GC的核心开发成员。在他的一片博客中提到的:
查看个别的暂停时间最长的GC操作,不应该总是关注full GC,因为full GC是可以再并发模式下完成的。所以老年代的GC(G2)可能会比其他两代更短。即使个别情况下,老年代的full GC时间最长,但是也不一定非要关注他,因为他的频率很低,相反,其他两代的GC才是GC暂停时间的主要组成部分,如果暂停时间对你产生了影响,那也应该检查一下他们的情况。
Tips:.NET GC分为0,1,2代,这里的其他两代值得是0,1代,2代暂时成为老年代。
所以如果GC暂停时间对你的应用程序产生了影响,请理性的分析他。
原文链接:Analysing Pause times in the .NET GC
译者:JYSDeveloper