.NET 4垃圾回收器的可扩展性

14

我最近对.NET 4垃圾回收器进行了基准测试,从多个线程进行大量分配。当分配的值记录在数组中时,我观察到没有可扩展性,这正如我所预期的那样(因为系统争用同步访问共享老年代)。然而,当分配的值立即被丢弃时,我感到十分震惊,因为我观察到此时也没有可扩展性!

我原本预计暂时情况下将近线性扩展,因为每个线程应该只需清空nursery gen0并开始新的分配,而不需要争用任何共享资源(没有东西存活到更老的代中,也没有L2缓存缺失,因为gen0轻松适应L1缓存)。

例如,这篇MSDN文章说:

无需同步的分配 在多处理器系统上,托管堆的第0代使用一个内存区域来划分多个内存单元,每个线程使用一个内存单元。这允许多个线程同时进行分配,因此不需要对堆进行互斥访问。

有人能够验证我的发现并/或解释我预测和观察之间的差异吗?


3
“无法扩展性”指系统或技术在应对需求增长时无法保持相同的效率、可靠性和性能水平,也可能导致部分或全部系统崩溃。 - Anon.
7
请您提供确切的方法论,包括您所测量的内容、如何进行测量以及测量数值。请确保翻译内容通俗易懂但不改变原意。 - Remus Rusanu
2
我猜测一下,也许Jon Harrop是在N核计算机上运行测试,并从n = 1到N线程进行基准测试。然后,扩展性是基准速度如何随n变化的方式。 - Martin Liversage
@Martin:没错。随着分配线程数量的增加,分配总速率保持不变。 - J D
你确定每个线程都在自己的核心上吗? - James Black
@James:我不能确定,但如果调度程序将线程放置在同一核心上,我会感到惊讶。 - J D
5个回答

12

不太确定这是关于什么,以及您在计算机上看到的情况。但是,在您的计算机上有两个不同版本的CLR:Mscorwks.dll和mscorsvc.dll。前者是当您在工作站上运行程序时得到的版本,后者是在某些服务器版本的Windows(如Windows 2003或2008)上得到的版本。

工作站版本对本地PC比较友好,它不会占用所有机器资源。在进行GC时,您仍然可以阅读电子邮件。而服务器版本则经过优化,可在服务器级硬件上进行扩展。具有大量的RAM(GC不会那么快启动)和大量的CPU核心(垃圾收集在多个核心上完成)。您引用的文章可能是在谈论服务器版本。

您可以在工作站上选择服务器版本,在您的.config文件中使用<gcServer>元素。


11

这并非是对问题的完整回答,只是为了澄清一些误解:.NET GC 仅在工作站模式下是并发的。在服务器模式下,它使用停止-全局并行GC。更多详细信息请查看此处。.NET 中的各个保育院主要是为了避免在分配时进行同步;尽管如此,它们仍然属于全局堆,并且不能分开收集。


1
它们仍然是全局堆的一部分,无法单独回收。这正是我需要知道的。谢谢! - J D

4
我可以猜测发生了以下两种情况:
(1) 如果您只有一个线程,并且在第0代中有M个空闲空间,则GC只会在分配了M字节后运行一次。
(2) 如果您有N个线程,而GC将第0代划分为每个线程的N/M空间,则GC将在每个线程分配N/M字节时运行。问题在于,GC需要“停止整个世界”(即暂停所有正在运行的线程)以标记来自线程根集的引用。这不是廉价的。因此,GC不仅会更频繁地运行,而且每次收集时会做更多的工作。
当然,另一个问题是,多线程应用程序通常不太友好缓存,这也可能会显著影响性能。
我认为这不是.NET GC问题,而是GC普遍存在的问题。我的一个同事曾经运行过一个简单的“乒乓球”基准测试,在两个线程之间使用SOAP发送简单的整数消息。当两个线程位于不同进程中时,基准测试的运行速度快了两倍,因为内存分配和管理完全解耦!

1
@Jon:可能有点晚了,所以我可能说错了,但是要求每个机器寄存器都由全局或堆栈插槽支持,不会抵消大量代码生成优化吗?此外,写屏障并非便宜。我的意思是:GC无法知道线程i是否向线程j通信引用了本地对象,因此它需要检查j的根以查找对i的nursery的引用。无论如何,我阅读了“多线程应用程序性能”下链接文章的内容,.NET GC是一种停止整个世界类型。 - Rafe
@Jon:为每个机器寄存器备份一个内存位置确实会花费很多成本。寄存器着色(这是一种主要的优化)的整个目的是尽量减少将寄存器溢出到堆栈的需要。如果您必须在内存中记录包含可能指针值的寄存器的每个更改,则寄存器着色的好处显著降低。 - Rafe
@Jon:我认为你说的写入屏障技术很昂贵。没有写入屏障,我的编译器可以用一个机器指令实现赋值操作。而有了写入屏障,几乎每个赋值都必须被检查以查看赋值目标是否为“本地”。这涉及范围检查和条件分支,比单个存储指令多得多的代码。写入屏障在函数式编程语言(它们是在那里开发的,如果我没记错)中很合理的原因是FPL中很少使用赋值。 - Rafe
2
@Jon:当然,垃圾回收只需要停止程序中所有线程来查找根集,但是考虑到实际发生的事情,停止整个程序和跟踪所有根集都相对昂贵。我认为我们可能在这里存在交叉目的。在VM上实现的语言中,.NET非常好。然而,这些语言往往与“更精简”的语言(例如C、C++、Mercury、OCaml)相比较较差。我认为这部分是因为VM设计中的权衡牺牲了一些性能以换取可移植性和灵活性。 - Rafe
@Rafe:“没有写屏障,我的编译器可以在单个机器指令中实现赋值操作。” 当然,这使得实现高效GC变得更加困难,在实践中,写屏障是无处不在的。 - J D
显示剩余6条评论

4
非常快,易于查看(直接在根处分配空值),大规模释放可能会欺骗GC变得急切,而本地缓存堆的整个概念是一个美好的梦想 :-) 即使您完全分离了线程本地堆(您没有这样做),句柄指针表仍然必须完全易失性,才能使其适用于一般多CPU情况。哦,请记住有许多线程,CPU缓存是共享的,内核需要优先考虑,所以不是全部都为您服务:-)
此外请注意,“带有双指针的堆”有2部分-内存块和句柄指针表(以便块可以移动但代码始终具有一个地址)。这种表是关键但非常精益的进程级资源,几乎唯一的压力方式就是用大量快速释放来淹没它-所以您成功了 :-))
总的来说,GC的规则是-泄漏:-)当然不是永远,但是尽可能长时间地进行。如果您记得人们如何四处告诉“不要强制GC收集”?那就是故事的一部分。此外,“停止世界”收集实际上比“并发”更有效,并且曾经被称为更好的名称循环窃取或调度器协作。只有标记阶段需要冻结调度程序,在服务器上会出现几个线程的爆发(N个核心无论如何都是空闲的:-)进行此操作。另一个原因是它可能使实时操作(例如播放视频)变得不稳定,就像较长的线程量子一样。
因此,如果您在短时间和频繁的CPU突发事件(小型分配,几乎没有工作,快速释放)方面与基础设施竞争,您所看到/测量的唯一事物将是GC和JIT噪声。
如果这是为了某些真实的东西,而不仅仅是进行实验,则最好使用堆栈上的大值数组(结构)。它们无法强制进入堆,并且像本地一样本地化,不受任何后门移动=>缓存必须喜欢它们:-)这可能意味着切换到“不安全”模式,使用正常指针并可能自己做一些分配(如果您需要像列表这样简单的东西),但这是为了将GC踢出去而付出的小代价:-)试图将数据强制放入高速缓存还取决于保持您的堆栈精益-请记住您不是孤单的。此外,给您的线程一些值得至少在发布之间进行几个量子的工作可能有所帮助。最坏的情况是您在单个量子内分配和释放。

3

还是解释一下我的预测和观察结果之间的差异?

基准测试很难。
对于一个不完全受你控制的子系统进行基准测试更加困难。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接