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

GC.AddMemoryPressure() not enough to trigger the Finalizer queue execution on time

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

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

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

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

这些索引是线程安全的,可以由多个 C# 工作者共享,因此同一个索引可能有多个引用。因此,我们不能随意调用 Dispose(),而是依靠垃圾收集器来跟踪引用共享,并最终在索引未被工作进程使用时触发索引的终结。

现在,问题是 我们 运行 内存不足 。完整集合实际上执行得相对频繁,但是,在内存分析器的帮助下,我们可以发现在进程耗尽内存时,大量 "dead" 索引实例被保存在终结队列中用完分页文件后。

如果我们添加一个调用 GC::WaitForPendingFinalizers() 的看门狗线程,然后在低内存条件下调用 GC::Collect(),我们实际上可以绕过这个问题,但是,从我们所读到的,调用 GC::Collect() 手动严重扰乱垃圾收集效率,我们不希望这样。

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

在这一点上,我们觉得我们需要在计数达到零时立即对 Dispose() 实施手动引用计数,但这似乎有点矫枉过正,尤其是因为内存压力的整个目的 API正是为了说明像我们这样的情况。

一些事实:

欢迎提出任何想法或建议

好吧,除了"if you want to dispose external resource explicitly you had to do it by yourself"没有答案了。

AddMemoryPressure() 方法不保证立即触发垃圾回收。相反,CLR 使用非托管内存 allocation/deallocation 统计信息来调整它自己的 gc 阈值,并且只有在认为合适时才会触发 GC。

请注意 RemoveMemoryPressure() 根本不会触发 GC(理论上它 可以 由于设置 GCX_PREEMP 等操作的副作用,但让我们为简洁起见跳过它)。相反,它会降低当前的 mempressure 值,仅此而已(再次简化)。

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

现在是坏消息:

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

  • GC 调整它限制尝试最小化昂贵的 GC2 收集(你对这些感兴趣,因为你正在使用长寿命索引对象添加它们总是提升到下一代由于终结器)。因此,使用巨大的内存压力值对 运行 时间进行 DDOS 可能会反击,因为您会将标准提高到足以使(几乎)没有机会通过设置内存压力来触发 GC。 (注意: 最后一个问题将在 new AddMemoryPressure() implementation 中修复,但肯定不是今天。

UPD: 更多详情。

好的,让我们继续:)

第 2 部分,或 "newer underestimate what _udocumented_ means"

正如我上面所说,您对 GC 2 集合感兴趣,因为您使用的是长期存在的对象。

众所周知,终结器 运行 几乎是在对象被 GC 后立即执行的(假设终结器队列中没有其他对象)。 作为证明:只是 运行 this gist.

真实 索引未被释放的原因非常明显:对象所属的那一代未被 GC 处理。 现在我们回到最初的问题。您如何看待必须分配多少内存才能触发 GC2 收集?

正如我上面所说的,实际数字没有记录在案。理论上,在您消耗非常大的内存块之前,可能根本不会调用 GC2。 现在真正的坏消息来了:对于服务器 GC "in theory" 和 "what really happens" 是一样的。

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 收集。我很确定你的分配要小得多:)

注意: 请注意,处理 GC1 代的对象并没有使它变得更好。 GC0 段的大小可能超过 500mb。您必须非常努力地触发 ServerGC 上的垃圾收集 :)

总结: Add/RemoveMemoryPressure 的方法对垃圾收集频率(几乎)没有影响,至少在服务器 GC 上是这样。

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

待续

we can find a very large number of "dead" index instances being held in the finalization queue

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

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

We have ensured (via custom, tracking C++ allocators) that every byte...

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

calling GC::Collect() manually severely disrupts garbage collection efficiency

不要惊慌。很有可能,由于您的非托管分配非常大,您很少收集 "naturally" 并且实际上需要强制分配。就像GC::AddMemoryPressure() 触发的那种,它就像调用GC::Collect() 一样"forced"。尽管它有一种避免收集过于频繁的启发式方法,但您现在可能并不特别关心:)

Garbage collector is running in concurrent server mode

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

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

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 给了我一个 OutOfMemoryExceptionBitmap class这件事在2005年是真的,到2015年依然如此。Bitmap class很重要,因为它在图书馆存在了很长时间。随着错误修复、性能改进,我认为如果它不能做我需要的事情,那么我需要改变我的需要

首先,关于一次性对象的事情是你需要自己调用Dispose。不,你真的需要自己调用它。 认真的。我建议在VisualStudio的代码分析上启用相关规则并适当使用using等。

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

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