.NET GC 暂停时间分析

.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 统计得出的。

image

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来控制保存的数量。


image

可以清晰地看到,暂停时间与存活对象数量是成比例的(线性),为什么是这样的?,让我们通过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暂停时间的问题,他被成为不安全的方式是有原因的。


image

结果显而易见,是有效的。但是也没什么可奇怪的,我们无法管理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

你可能感兴趣的:(.NET GC 暂停时间分析)