65.9K
CodeProject 正在变化。 阅读更多。
Home

避免和修复 .NET 应用程序意外内存问题

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2013年11月1日

CPOL

7分钟阅读

viewsIcon

27360

如何追踪和修复意外的内存泄漏——一个实际示例。

一个曾经稳定的 Web 应用程序突然崩溃,抛出 OutOfMemory 异常——这显然不是什么好事。不幸的是,一个应用程序——无论是 Web 还是桌面——在开发和 QA 阶段可能运行得非常完美,但在生产环境中,在重负载、多用户情况下,或者仅仅是随着时间的推移,却可能以一种戏剧性的方式崩溃。

发生这种情况有很多原因,其中最常见且最棘手的诊断问题之一就是内存泄漏。本文简要介绍了意外内存问题如何悄悄地潜入 .NET 代码。然后,通过一个简单的故障排除示例,使用一个 ASP.NET 应用程序和 **ANTS Memory Profiler** 进行演示。

托管内存、非托管内存以及错误如何产生

在 .NET 中工作确实简化了内存管理,但并未完全消除问题。至少,理解垃圾回收和对象堆有助于您避免因内存管理而带来的可怕性能开销。但您也很可能遇到非托管内存的问题,而这些问题您可能并未意识到您正在使用。

例如,在底层,标准的 .NET 框架图像库通常会使用大量的非托管内存,即使您是通过 .NET 包装器与之交互。这些可能会导致内存泄漏,在大量使用时,它们会以一种不直观的方式减慢甚至崩溃应用程序——在编写 .NET 代码时,并不总是显而易见去寻找非托管内存问题。

同样,在一个复杂的代码库中,很容易忘记取消注册事件处理程序。这些事件处理程序随后可能会占用内存,并导致内存使用量随时间增加,从而逐渐降低性能,并可能导致崩溃。

定期对应用程序进行性能分析不仅有助于您修复 OutOfMemory 异常等明显问题,还可以提前提醒您潜在问题,避免在生产环境中看到那些令人讨厌的崩溃。一个简单的例子是,看到垃圾回收的第二代堆 (Generation 2 heap) 中有很高的内存比例,这表明内存被长时间占用,并且您可能存在内存泄漏。

内存分析——比较前后

使用 **ANTS Memory Profiler** 进行分析是基于拍摄内存快照。分析器会附加到应用程序,当您拍摄快照时,它会检查正在使用的内存状态。

当您使用 ANTS Memory Profiler 时,您关注的是快照之间的*差异*。分析器会显示一个带有持续性能计数器的时间线,作为应用程序一般行为的概述,并指导您何时拍摄快照最佳。

一个好的方法是,在应用程序空闲时拍摄一个基线快照,然后施加负载或执行您正在排查错误的复现步骤。

如果存在问题,内存使用量将在时间线上急剧上升,并且会保持在高位或以低于预期的速率下降。此时拍摄第二个快照可以让您查看发生了什么变化,并找出哪些对象在内存中停留的时间比应有的长。

我们将使用一个简单的示例 Web 应用程序更详细地介绍这一点。

示例案例:泄漏的 Web 应用程序

在此示例中,我们使用了 NerdDinner(一个 ASP.NET MVC 演示应用程序)并对其进行了修改,以展示一个相当常见的问题。

NerdDinner 在地图上显示地点,我们添加了使用第三方 PDF 库将该地图输出为 PDF 的功能。

 

但我们修改后的 NerdDinner 版本在有多个并发用户时,据报告会急剧减慢,甚至因 OOM 异常而崩溃。

这并不理想。因为它在添加新功能之前是稳定的,并且在轻度使用下仍然稳定,所以我们对从哪里开始调查有了一个大致的想法——我们将对新的 PDF 导出功能施加负载,并观察图表的变化。

我们将这样做:

  1. 打开 NerdDinner
  2. 在空闲时拍摄基线快照
  3. 对 PDF 功能产生负载
  4. 拍摄第二个快照进行比较
  5. 检查分析器数据,查看是否存在内存泄漏以及泄漏在哪里

设置很简单。我们只需启动分析器并单击*新建分析会话*。

如果您使用过之前的版本,您可能会注意到 8.0 版本看起来有些不同。特别是,它启动和重新运行分析会话更快,并且允许您使用任何 Web 浏览器进行分析。

在屏幕左侧,我们选择*IIS - ASP.NET*。

我们输入 Web 应用程序的位置,确保选择了分析非托管代码的选项,然后单击*开始分析*。

NerdDinner 在浏览器中启动,分析器开始收集数据。我们开始在时间线上看到内存使用情况。

此时,我们拍摄基线快照。

摘要屏幕显示了有关内存使用情况的一些基本信息,但直到我们拍摄另一个快照,它才变得真正有趣。

这是基线:

为了模拟负载并触发问题,我们将使用 TinyGet 向 PDF 导出函数发送多个请求。

内存使用量在时间线上开始急剧上升,我们再拍摄一个快照。

摘要屏幕现在显示了我们的基线和施加负载之间的变化。在这种情况下,结果相当清晰。

饼图显示,大量的内存被非托管代码占用。

要查看内存去向,我们可以使用*按模块划分的非托管代码细分*。这显示了 855MB 的内存由 MuPDFlib 使用,这是我们已知的新的 PDF 组件的模块。旁边其他模块的小灰色条是基线快照中的大小。我们的 PDF 模块没有条,所以除了比其他任何模块都大得多之外,我们知道这是新分配的内存。

因此,与此模块相关的类是我们开始查找问题的正确位置。

是什么导致了泄漏?

要找出原因,我们转到*类列表*并按非托管内存大小排序。

我们看到,虽然 MuPDF .NET 类使用了大量的非托管内存,但其 .NET 内存消耗相对较小。以至于如果我们没有选择“非托管代码分析”,它很可能会被忽略。

接下来,我们查看实例列表,其中看到内存中有多个 MuPDF 实例,占用了大量非托管空间。

这证实了该类可能是罪魁祸首,因此我们可以继续绘制实例保留图,找出内存被占用的原因。

在这种特殊情况下,图表几乎简单得可笑——MuPDF 被保留在终结器队列中。

这有点奇怪,此时我们需要深入检查代码,找出原因。

修复泄漏

我们的示例相对容易导航。我们转到 MuPDF 实现的终结器。

~MuPDF()
{
    if (this.m_pNativeObject != IntPtr.Zero)
    {
        this._Api.DisposeMuPDFClass(this.m_pNativeObject);
        this.m_pNativeObject = IntPtr.Zero;
        if (this._ImagePin.IsAllocated)
        {
            this._ImagePin.Free();
        }
    }
    Logger.Logging.logMessage("Finalized");
}

应用程序正在记录每次终结器运行时的情况。

.NET 只有一个终结器线程,因此,由于我们使用的日志记录系统需要很长时间才能与数据库通信,该线程被阻塞,阻止它清理对象,并导致它们在内存中停留的时间比应有的长。

如果我们回到时间线,现在我们停止产生负载,也能看到一些有趣的东西。

应用程序的内存使用量实际上在非常缓慢地下降,而不是保持高位和恒定。所以内存是在日志记录完成后释放的,但比负载下分配的速度慢得多。这就是为什么直到应用程序部署到生产环境后,我们才注意到这个问题。

在这种情况下,有一些简单的修复方法。我们可以删除终结器日志记录,或者排查数据库查询以减少延迟。由于日志记录可能是开发中某些调试工具的一部分,并且这并不是一件非常明智的事情,我们将直接将其删除。或者,一个更好的解决方案是实现 IDisposable

此时,我们重建应用程序,并重新运行分析会话(使用相同的方法)来检查问题是否已修复。

我们在这里可以看到,内存使用量恢复到可接受水平的速度比以前快得多。

结论

我们的演练展示了一个相当简单的故障排除示例:调试进入生产环境的代码。但其后果是真实存在的——一个不明显的内存泄漏,其后果仅在应用程序重负载使用时才会显现。实际的 .NET 内存使用量看起来并不 suspicious,只有当我们检查 .NET 代码使用的非托管内存时,问题的根源才会浮现。

注意:Red Gate Software 提供 **ANTS Memory Profiler 的免费试用版**,供您在自己的应用程序上试用。

© . All rights reserved.