GC.AddMemoryPressure()不足以按时触发Finalizer队列

时间:2015-11-03 20:43:25

标签: c# c++ .net memory-management garbage-collection

我们为C#编写的多媒体匹配项目编写了自定义索引引擎。

索引引擎是以非托管C++编写的,可以以std::集合和容器的形式保存大量非托管内存。

每个非托管索引实例都由托管对象包装; unamanaged索引的生命周期由托管包装器的生命周期控制。

我们已经确保(通过自定义,跟踪C ++分配器)正在考虑索引内部消耗的每个字节,并且我们更新(每秒10次)托管垃圾收集器的内存压力值具有此值的增量(正增量调用GC.AddMemoryPressure(),负增量调用GC.RemoveMemoryPressure())。

这些索引是线程安全的,可以由许多C#worker共享,因此可能有多个引用正在使用同一索引。出于这个原因,我们不能自由地调用Dispose(),而是依赖垃圾收集器来跟踪引用共享,并最终在工作进程未使用它们时触发索引的最终确定。

现在,问题在于我们的内存不足。事实上,完整的集合经常被执行,但是,在内存分析器的帮助下,我们可以找到大量的" dead"索引实例在耗尽文件耗尽后,在进程耗尽内存的时间点保存在终结队列中。

如果我们在低内存条件下添加一个调用GC::WaitForPendingFinalizers()后跟GC::Collect()的监视程序线程,我们实际上可以避免这个问题,但是,从我们读过的内容,手动调用GC::Collect()严重破坏垃圾收集效率,我们不希望这样。

我们甚至添加了一个悲观的压力因素(尝试高达4倍)来夸大报告给.net端的非托管内存量,看看我们是否可以哄骗垃圾收集器以更快地清空队列。似乎处理队列的线程完全没有意识到内存压力。

此时我们觉得一旦计数达到零,我们就需要实现一个手动引用计数到Dispose(),但这似乎是一种矫枉过正,特别是因为内存压力API的整个目的正是如此为我们这样的案件负责。

一些事实:

  • .Net版本为4.5
  • 应用程序处于64位模式
  • 垃圾收集器以并发服务器模式运行。
  • 索引的大小是~800MB的非托管内存
  • 最多可以有12个"活着"任何时间点的索引。
  • 服务器有64GB的RAM

欢迎任何想法或建议

3 个答案:

答案 0 :(得分:7)

嗯,没有答案,但“如果你想明确地处理外部资源,你必须自己做”。

AddMemoryPressure()方法不保证立即触发垃圾回收。相反,CLR使用非托管内存分配/释放统计来调整它自己的gc阈值,只有在认为合适的情况下才会触发GC。

请注意,RemoveMemoryPressure()根本不会触发GC(理论上它可以执行此操作,因为来自操作的副作用,例如设置GCX_PREEMP,但为了简洁起见,我们跳过它)。相反,它会降低当前的压力值,仅此而已(再次简化)。

实际算法未记录,但您可以查看实现from CoreCLR。简而言之,您的bytesAllocated值必须超过某个动态计算的限制,然后CLR才会触发GC。

现在是坏消息:

  • 在真实的应用程序中,由于每个GC集合和每个第三方代码都会对GC限制产生影响,因此该过程完全不可预测。可以调用GC,稍后可能调用GC可能根本不调用

  • GC调整它限制尝试最小化昂贵的GC2集合(你对这些感兴趣,因为你正在处理长寿命的索引对象,因为终结器它们总是被提升到下一代)。因此,DDOS运行时具有巨大的内存压力值可能会反击,因为您将提高标准值以使(几乎)没有机会通过设置内存压力来触发GC。 ( NB:最后一期将以new AddMemoryPressure() implementation修正,但今天不会修正。

UPD:更多细节。

好的,让我们继续:)

第2部分,或“较新的低估了_udocumented_的含义”

正如我上面所说,当您使用长寿命对象时,您对GC 2集合感兴趣。

众所周知,终结器在对象进行GC编辑后几乎立即运行(假设终结器队列未填充其他对象)。 作为证明:只需运行this gist

未释放索引的真正的原因非常明显:对象所属的生成不是GCed。 现在我们回到最初的问题。您如何看待,您需要分配多少内存才能触发GC2集合?

正如我上面所说,实际数字没有记录。理论上,在消耗非常大的内存块之前,可能根本不会调用GC2。 而现在真正的坏消息是:对于服务器GC“在理论上”和“真正发生的事情”是相同的。

One more gist,在.Net4.6 x64上,输出结果与此类似:

GC low latency:
Allocated, MB:   512.19          GC gen 0|1|2, MB:   194.19 |   317.81 |     0.00        GC count 0-1-2: 1-0-0
Allocated, MB: 1,024.38          GC gen 0|1|2, MB:   421.19 |   399.56 |   203.25        GC count 0-1-2: 2-1-0
Allocated, MB: 1,536.56          GC gen 0|1|2, MB:   446.44 |   901.44 |   188.13        GC count 0-1-2: 3-1-0
Allocated, MB: 2,048.75          GC gen 0|1|2, MB:   258.56 | 1,569.75 |   219.69        GC count 0-1-2: 4-1-0
Allocated, MB: 2,560.94          GC gen 0|1|2, MB:   623.00 | 1,657.56 |   279.44        GC count 0-1-2: 4-1-0
Allocated, MB: 3,073.13          GC gen 0|1|2, MB:   563.63 | 2,273.50 |   234.88        GC count 0-1-2: 5-1-0
Allocated, MB: 3,585.31          GC gen 0|1|2, MB:   309.19 |   723.75 | 2,551.06        GC count 0-1-2: 6-2-1
Allocated, MB: 4,097.50          GC gen 0|1|2, MB:   686.69 |   728.00 | 2,681.31        GC count 0-1-2: 6-2-1
Allocated, MB: 4,609.69          GC gen 0|1|2, MB:   593.63 | 1,465.44 | 2,548.94        GC count 0-1-2: 7-2-1
Allocated, MB: 5,121.88          GC gen 0|1|2, MB:   293.19 | 2,229.38 | 2,597.44        GC count 0-1-2: 8-2-1

这是对的,在最坏的情况下,你必须分配~3.5 gig来触发GC2收集。我很确定你的分配要小得多:)

NB:请注意,处理GC1生成中的对象并不会让它变得更好。 GC0段的大小可能超过500mb。您必须非常努力地在ServerGC上触发垃圾收集:)

摘要:使用Add / RemoveMemoryPressure的方法(几乎)不会影响垃圾回收频率,至少在服务器GC上是这样。

现在,问题的最后一部分:我们有什么可能的解决方案? 简而言之,最简单的方法是通过一次性包装进行重新计数。

待续

答案 1 :(得分:3)

  

我们可以在终结队列中找到大量“死”索引实例

这些“死”的实例没有最终确定没有任何意义。毕竟,你发现GC :: WaitForPendingFinalizers()实际上是有效的。所以这里必须要做的是它们实际上是最终确定的,它们只是等待 next 集合运行以便它们可以被销毁。这需要一段时间。是的,这并非不可能,毕竟你已经为他们调用了GC :: RemoveMemoryPressure()。并且,希望为他们发布了大量的非托管分配。

所以这肯定只是一个错误信号,这些对象只占用GC堆,而不是非托管堆和GC堆不是你的问题。

  

我们确保(通过自定义,跟踪C ++分配器)每个字节......

我不太喜欢那种声音。非常重要的是GC调用与实际创建和完成托管对象的某些对应关系。很简单,在调用C ++ delete运算符之后,在构造函数中调用AddMemoryPressure,在终结器中调用RemoveMemoryPressure。您传递的值只需要是对应的C ++非托管分配的估计值,它不必精确到字节,关闭2倍并不是一个严重的问题。 C ++分配在以后发生也没关系。

  

手动调用GC :: Collect()会严重破坏垃圾收集效率

不要惊慌。非常高的赔率,因为你的非托管分配如此之大,你很少“自然地”收集并且实际上需要强制分配。就像GC :: AddMemoryPressure()触发的类型一样,它就像调用GC :: Collect()一样“强制”。虽然它有一个避免过于频繁收集的启发式方法,但你现在可能并不特别关心:)

  

垃圾收集器以并发服务器模式运行

不要使用工作站GC,它对堆段大小要保守得多。

答案 2 :(得分:2)

我想建议简要阅读" Finalizers are not guaranteed to run"。您可以通过自己不断生成好的旧Bitmap来轻松测试它:

private void genButton_Click(object sender, EventArgs e)
{
    Task.Run(() => GenerateNewBitmap());
}

private void GenerateNewBitmap()
{
    //Changing size also changes collection behavior
    //If this is a small bitmap then collection happens
    var size = picBox.Size;
    Bitmap bmp = new Bitmap(size.Width, size.Height);
    //Generate some pixels and Invoke it onto UI if you wish
    picBox.Invoke((Action)(() => { picBox.Image = bmp; }));
    //Call again for an infinite loop
    Task.Run(() => GenerateNewBitmap());
}

在我的机器上看来,如果我生成的像素超过500K,我就无法永久生成,.NET会给我一个OutOfMemoryException。 关于Bitmap类的这个问题在2005年是正确的,并且在2015年仍然如此。Bitmap类很重要,因为它在库中存在了很长时间。修复错误,一路上的性能改进,我认为如果它不能做我需要的事情,那么我需要改变我的需求

首先,关于一次性物品的事情是你需要自己打电话给Dispose。不,你真的需要自己打电话。的严重即可。我建议在VisualStudio的代码上启用相关规则,并适当地使用using等。

其次,调用Dispose方法并不意味着在非托管端调用delete(或free)。我所做的,我认为你应该使用引用计数。如果您的非托管方使用C ++,那么我建议使用shared_ptr。自VS2012以来,据我所知,VisualStudio支持shared_ptr

因此,通过引用计数,在托管对象上调用Dispose会减少非托管对象的引用计数,并且仅当引用计数减少到零时才会删除非托管内存。

相关问题