当有大量内存可用时出现OutOfMemoryException

15

我们有一个应用程序运行在5个(服务器)节点上(每个节点有16个核心,128 GB内存),每台机器上加载近70 GB的数据。该应用程序是分布式的,可以为并发客户端提供服务,因此有很多套接字使用。同样,为了在多个线程之间进行同步,使用了一些同步技术,主要使用了System.Threading.Monitor

现在的问题是,当应用程序正在运行并且数据正在服务器节点和客户端之间传输时,其中一两台服务器机器开始收到OutOfMemoryException异常,即使仍然有40%以上的内存可用。我们有一个感觉这个异常来自非托管代码。虽然我们没有直接调用任何非托管函数,但我们已经看到OOM异常堆栈跟踪中的最后一个调用总是一个框架调用,它在内部调用非托管代码。

以下是几个例子。

Exception of type 'System.OutOfMemoryException' was thrown.
   at System.Threading.Monitor.ObjPulseAll(Object obj)
   ....

Exception of type 'System.OutOfMemoryException' was thrown.
   at System.Threading.Monitor.ObjWait(Boolean exitContext, Int32 millisecondsTimeout, Object obj)
   at System.Threading.Monitor.Wait(Object obj, TimeSpan timeout)
   ....

我们对导致此问题的原因毫无头绪。我们已经多次在这些机器上进行了GC垃圾回收,但也似乎没有起到帮助作用。

任何帮助将不胜感激。

编辑:

以下是一些更多的细节:

  • 应用程序在x64进程中运行。
  • Windows Server 2012 R2
  • .NET Framework 4.5
  • 启用了服务器GC垃圾回收。
  • AllowLargeObject标志已设置。

编辑2:请注意,这不是内存泄漏。70GB的进程大小在这里是有效的。


1
你使用的是几位元的操作系统?基于操作系统是32/64位,每个进程都有限制。 - toadflakz
2
托管代码仍然可能会抛出OOM异常,如果您尝试超过大多数对象的2GB限制。 - Mitch Wheat
你的对象有多大?也许你没有足够的连续内存区域? - hazimdikenli
2
你是如何确定LOH的大小的?有多少个LOH?如果有多个,每个LOH的大小是10GB还是所有LOH组合的大小为10GB?此外,Pagefile是启用还是禁用?页面文件有多大?操作系统显示的可用内存是多少? - Vikas Gupta
1
你是否使用过任务管理器或进程资源管理器来查找当前正在使用的句柄数、GDI对象数和用户对象数? - Gui
显示剩余6条评论
10个回答

6

1
关于应用程序的性能分析,它有点懒惰...这是很多工作 :)(在我看来)。无论如何,对于加载70GB数据的应用程序进行性能分析可能仍然非常棘手... - Vikas Gupta
ANTS是一个非常好的工具。我过去曾经遇到过内存问题,但由于这个工具的帮助,我能够检测出问题所在。 - Miguel
是的,它们应该很有帮助,但不适用于70 GB的进程。我严重怀疑这些分析工具是否曾经在如此大的数据上进行过测试。 - Faisal

5

即使存在来自非托管代码的内存泄漏,如果您有40%的可用内存,应该能够分配对象。我认为这是一个碎片化问题而不是内存泄漏。

1- 您尝试分配的数据块是大块还是小块?

2- 您是否尝试强制垃圾回收(通过调用GC.Collect())?垃圾回收不仅释放内存,而且压缩它以消除碎片化。


1
是的,这不是内存泄漏。最有可能是碎片化问题。数据也以大块形式存在,LOH 的使用量约为 10 GB。我们已经尽可能地通过重复使用数组和缓冲区来优化其使用。是的,我们通过调用带有参数的 GC.Collect() 强制进行了垃圾回收,该参数还收集了 Gen 2 堆和 LOH。 - Faisal
2
碎片化相关的OOM是指虚拟空间碎片化,因此VirtualAlloc无法找到空闲地址范围。在64位系统上,虚拟空间非常巨大,以至于很难出现如此碎片化的情况导致OOM。而且你不能在不释放内存的情况下压缩虚拟空间。 - facetus

3

GC.Collect()仅在对象没有被其他任何东西引用时才会释放内存。

一个常见的泄漏场景是在将对象引用设置为null之前未从对象中断开事件处理程序。

为了避免泄漏,实现IDisposable接口是一个好主意(即使它是用于释放非托管对象),从确保所有处理程序都已断开连接、集合已正确清除以及任何其他对象引用已设置为null的角度来看。


3
我建议在发生此异常时使用ADPlus或其他工具获取进程的转储。使用这个转储,您可以使用WinDbg调试您的转储文件。下面的所有命令都来自博客文章Investigating ASP.Net Memory Dumps for Idiots (like Me)

调查内存泄漏

为了查看内存,我们需要使用以下命令

!dumpheap

"

\"dumpheap\" 命令将给出对象计数和对象的内存使用情况。然后您可以调查哪些对象类型使用了大部分内存。

"
!dumpheap -type System.IO.MemoryStream

"dumpheap -type" 命令将列出堆上所有类型为MemoryStream的对象。WinDbg的好处在于您可以调查非托管内存泄漏:示例1示例2

70 GB 的转储文件在 windbg 中打开几乎要花费很长时间 :( - Faisal
如何在生产环境中进行调查? - Kiquenet
@Kiquenet ADPlus适用于生产环境,开发中多少有些不必要。 - Atilla Ozgur

2

如果是碎片化问题,那么您无法解决它而不进行一定程度的剖析。搜索支持碎片检测的内存剖析工具,以了解这种碎片化的确切原因。


1
使用LargeObjectHeapCompactionMode = CompactOnce的垃圾回收可能有助于解决碎片问题。
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

1
请注意,当事件处理程序被订阅时,事件的发布者会持有对订阅者的引用。这是.NET中常见的内存泄漏原因之一,在您的情况下,它可能不是一个严重的泄漏,但如果托管对象保留指向未托管对象的指针或句柄,则不会删除此未托管对象,从而导致内存碎片化。
如果您确定碎片化的原因是未托管组件,并且您没有遗漏任何内容,并且如果您可以访问未托管组件的代码,则可以重新编译并使用像hoard这样的良好内存分配器进行链接。但这应该在没有其他事情可做并且经过严格的分析后才能执行。

1
在.NET 4.5中,CLR团队增强了大对象堆(LOH)分配。即使如此,他们仍然建议使用对象池来帮助大对象性能。听起来LOH碎片化在4.5中发生的不太频繁,但仍可能发生。但从堆栈跟踪来看,它与LOH无关。
Daniel Lane提到了GC死锁。我们也在生产系统中看到过这种情况,它肯定会导致进程大小和内存不足的问题。
你可以做的一件事是运行Debug诊断工具,在OutOfMemoryException发生时捕获完整转储,然后让工具分析转储以获取崩溃和内存信息。我曾经从这个报告中看到一些有趣的东西,包括本地和托管堆。例如,我们发现一个打印机驱动程序在32位系统上分配了1 GB的非托管堆。更新驱动程序解决了该问题。当然,那是客户端系统,但类似的情况可能发生在您的服务器上。
我认为这听起来像是本地模式错误。查看 .NET 4.5 Reference CodeSystem.Threading.Monitor.WaitObjWaitPulseAllObjPulseAll 的实现可以发现这些类在调用本地方法。请注意,保留html标签。
   /*========================================================================
    ** Sends a notification to all waiting objects. 
    ========================================================================*/
    [System.Security.SecurityCritical]  // auto-generated
    [ResourceExposure(ResourceScope.None)]
    [MethodImplAttribute(MethodImplOptions.InternalCall)]
    private static extern void ObjPulseAll(Object obj);

    [System.Security.SecuritySafeCritical]  // auto-generated
    public static void PulseAll(Object obj)
    {
        if (obj == null)
        {
            throw new ArgumentNullException("obj");
        }
        Contract.EndContractBlock();

        ObjPulseAll(obj);
    }

一篇关于"PulseEvent存在根本缺陷"的Raymond Chen文章的评论,由“Mike Dimmick”发表:

Monitor.PulseAll是Monitor.ObjPulseAll的包装器,它是对CLR内部函数ObjectNative::PulseAll的内部调用。这又包装了ObjHeader::PulseAll,该函数又包装了SyncBlock::PulseAll。这个函数简单地循环调用SetEvent,直到没有更多线程在等待对象。

如果有人可以访问CLI的源代码,也许他们可以发布更多关于此函数及其可能引起的内存错误的信息。


1
70 GB的转储文件在windbg中几乎需要永远才能打开:(。但是你的答案真的很有帮助。 - Faisal

0

没有看到你的代码,但我猜测你可能在STA最终化死锁方面存在问题,特别是考虑到你庞大的硬件需求,这是一个高并发系统。如果你已经尝试强制GC,那么死锁就有意义了,如果最终化被死锁了,那么GC将无法完成其工作。希望这能帮到你。

.Net应用程序中预防和检测死锁的高级技术

具体来说,下面引用的部分是感兴趣的部分

当您的代码在单线程公寓(STA)线程上执行时,相当于发生了独占锁。只有一个线程可以更新GUI窗口或在STA中运行Apartment-threaded COM组件内的代码。这些线程拥有一个消息队列,系统和应用程序的其他部分将待处理信息放入其中。GUI使用此队列来处理诸如重绘请求、要处理的设备输入和窗口关闭请求等信息。COM代理使用消息队列将跨公寓方法调用转换为具有亲和性的组件所属的公寓。在STA上运行的所有代码都负责泵送消息队列-使用消息循环查找和处理新消息-否则队列可能会变得堵塞,导致响应丢失。在Win32术语中,这意味着使用MsgWaitForSingleObject、MsgWaitForMultipleObjects(及其Ex对应项)或CoWaitForMultipleHandles API。像WaitForSingleObject或WaitForMultipleObjects(及其Ex对应项)这样的非泵送等待不会泵送传入的消息。
换句话说,STA“锁”只能通过泵送消息队列来释放。执行操作的应用程序,如果其性能特征在GUI线程上变化很大而没有为消息泵送,则很容易死锁。编写良好的程序要么安排这样的长时间运行工作在其他地方进行,要么每次阻塞时都要泵送消息以避免出现此问题。值得庆幸的是,当您在托管代码中阻塞(通过对有争议的Monitor.Enter、WaitHandle.WaitOne、FileStream.EndRead、Thread.Join等的调用)时,CLR会为您泵送,从而有助于缓解此问题。但是,大量的代码-甚至是.NET Framework本身的一部分-最终会在非托管代码中阻塞,在这种情况下,阻塞代码的作者可能已经添加了泵送等待,也可能没有。
以下是STA引起死锁的经典示例。在STA中运行的线程生成大量的Apartment threaded COM组件实例和它们对应的Runtime Callable Wrappers(RCWs)。当然,这些RCW必须由CLR在它们变得不可达时进行最终处理,否则它们将泄漏。但是,CLR的终结器线程始终加入进程的多线程公寓(MTA),这意味着它必须使用代理来转换为STA,以便在RCWs上调用Release。如果STA选择使用非泵送等待来阻塞,以致于无法接收终结器尝试调用给定RCW上的Finalize方法,则终结器线程将被阻塞。它被阻塞直到STA解除阻塞并进行泵送。如果STA从未进行泵送,则终结器线程将永远无法取得任何进展,并且所有可终结资源将随着时间的推移而缓慢、静默地积累。这反过来可能导致后续的内存不足崩溃或ASP.NET中的进程回收。显然,这两种结果都是不令人满意的。
高级框架(如Windows Forms、Windows Presentation Foundation和COM)隐藏了STAs的大部分复杂性,但它们仍然可能以不可预测的方式失败,包括死锁。COM同步上下文引入了类似但微妙不同的挑战。此外,许多这些故障只会在少数测试运行中发生,通常只在高压力下

https://msdn.microsoft.com/en-us/magazine/ee310108.aspx 未找到。请问在《MSDN Magazine》上是哪一年哪一个月份? - Kiquenet
@Kiquenet,我相信是在2006年4月。 - Daniel Lane

0

垃圾回收器不考虑非托管堆。如果您正在创建许多仅是C#中较大的非托管内存的包装器对象,则会消耗您的内存,但GC无法基于此做出理性决策,因为它只看到托管堆。

您最终会陷入这样一种情况:GC收集器认为您没有内存短缺,因为您的第1代堆上的大部分内容都是8字节引用,而实际上它们就像海上的冰山一样。大部分内存在下面!

您可以利用这些GC调用:

System::GC::AddMemoryPressure(sizeOfField);
System::GC::RemoveMemoryPressure(sizeOfField);

如果您提供正确的数字,这些方法可以让垃圾回收器查看非托管内存。


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