使用 WinDbg 诊断 .NET 应用中的内存泄漏:实战指南

4 阅读9分钟

使用 WinDbg 诊断 .NET 应用中的内存泄漏:实战指南

引言

在本文中,我将分享通过排查真实生产环境中运行的 .NET 服务的内存泄漏问题,所学到的关于 .NET 内存管理的知识。阅读完本文后,你将更深入地了解以下内容:

  • .NET 内存管理与 .NET 垃圾回收
  • 使用 WinDbg 诊断内存使用情况

我之前曾发表过关于 Linux 系统上用 C 编写的原生应用内存分配的文章。强烈建议你阅读这两篇文章并对比差异,这可以加深你对内存相关技术的理解。

背景

首先,让我解释一下背景:出问题的 .NET 服务运行在 Azure Service Fabric 上,它由一组 Windows Server 集群组成。该 .NET 服务的内存使用量达到了约 1.5GB,是平均使用量的 4 倍。一般来说,云上资源使用越多,成本越高。接下来,让我们诊断一下发生了什么。但在动手调试之前,我们先从理论层面回顾一下 .NET 内存的工作原理。

.NET 内存管理

.NET 运行时

与直接调用操作系统 API 的原生软件不同,使用 .NET 编写的程序被称为 托管应用程序,因为它们依赖运行时和框架来管理许多关键任务,并确保基本的运行环境安全。在 .NET 中,运行时指的是 公共语言运行时 (CLR),框架是 .NET Framework 或 .NET Core。.NET 应用就构建在这些基础之上。

CLR 作为 .NET 应用的虚拟执行引擎,这是一个非常复杂的话题,超出了本文范围,我们无法深入细节。但它提供的关键功能之一正是:内存管理

.NET 垃圾回收

当你使用 C 或 C++ 进行系统编程时,需要调用标准库 API(如 mallocfree)来管理内存。但在 .NET 框架上,你的工作会更轻松,因为 .NET 内存管理器会为你完成这项任务。当程序创建新对象时,内存管理器会自动分配内存。很简单,对吧?但这只是内存管理器的一小部分,复杂的任务在于内存管理器如何以及何时决定回收内存。正因如此,.NET 内存管理器还有另一个名字:.NET 垃圾回收器 (GC)

.NET GC 使用两个 Win32 函数:VirtualAlloc 用于分配内存段,VirtualFree 用于将内存段释放回操作系统。整个过程可以分为以下三个阶段:

  1. 标记阶段:在此阶段创建存活对象列表。这从一组称为 GC 根 的引用开始。GC 将这些根对象标记为存活,然后查看它们引用的任何对象,并将这些对象也标记为存活。GC 继续迭代搜索。这个过程也称为可达性分析。所有不在可达列表中的对象都可能从内存中释放。

  2. 重定位阶段:更新所有可达对象的引用,使其指向新位置。重定位阶段的目的是通过将存活对象压缩得更紧密来优化内存使用。这有助于减少内存碎片,提高内存分配效率。

  3. 压缩阶段:释放死对象占用的内存空间,并将存活对象移动到新位置。

GC 算法是一个复杂的主题,以上描述仅触及皮毛。你可以自行进一步探索。在以下章节中,我将在必要时补充额外信息。

使用 WinDbg 诊断

WinDbg 原本用于调试原生应用,但它允许加载扩展,这些扩展可以增强调试器支持的命令。例如,要调试在 CLR 上运行的 .NET 应用程序,我们需要 SOS 扩展。

现在我们有了强大的调试器,下一个问题是如何使用它。一般来说,你可以将调试器附加到有问题的服务上,但由于目标服务运行在生产环境中,我们不能轻易这样做。因此,我们需要通过离线方式排查此问题:

  • 使用 ProcDump 将内存信息转储到一个文件中,称为转储文件
  • 然后使用 WinDbg 分析转储文件。

接下来,让我们操作一下转储文件吧!开始!

诊断内存泄漏

首先,使用 WinDbg 的 !address 扩展来显示目标进程或目标计算机使用的内存信息:

0:000> !address -summary

------------- RgnCount ----------- Total Size -------- %ofBusy %ofTotal
Free                                    338     7ffe`d06bb000 ( 127.995 TB)          100.00%
<unknown>                              4715        0`feb0f000 (   3.980 GB)  83.90%    0.00%
Stack                                   276        0`16880000 ( 360.500 MB)   7.42%    0.00%
Image                                  1241        0`0e989000 ( 233.535 MB)   4.81%    0.00%
Heap                                    519        0`0b997000 ( 185.590 MB)   3.82%    0.00%
Other                                    15        0`001cd000 (   1.801 MB)   0.04%    0.00%
TEB                                      92        0`000b8000 ( 736.000 kB)   0.01%    0.00%
PEB                                       1        0`00001000 (   4.000 kB)   0.00%    0.00%

--- RgnCount ----------- Total Size -------- %ofBusy %ofTotal
MEM_PRIVATE                            5051        1`1e9d4000 (   4.478 GB)  94.41%    0.00%
MEM_IMAGE                              1753        0`1013b000 ( 257.230 MB)   5.30%    0.00%
MEM_MAPPED                               55        0`00e26000 (  14.148 MB)   0.29%    0.00%

------------- RgnCount ----------- Total Size -------- %ofBusy %ofTotal
MEM_FREE                                338     7ffe`d06bb000 ( 127.995 TB)          100.00%
MEM_RESERVE                            2503        0`cdc3c000 (   3.215 GB)  67.78%    0.00%
MEM_COMMIT                             4356        0`61cf9000 (   1.528 GB)  32.22%    0.00%

让我们分析输出结果,它提供了非常多的信息!

  • 第一部分显示内存使用摘要:

    • Free:是可以从操作系统申请的整个虚拟内存。这可能包括交换空间,不仅仅是物理内存。对于 64 位进程,虚拟内存为 128TB。
    • Heap:这里的 Heap 是通过 Windows 堆管理器分配的内存,通常称为原生堆
    • Unknown:其他堆管理器会实现自己的内存管理。基本上,它们的工作方式类似:从 VirtualAlloc 获取大块内存,然后尝试在该大块内更好地管理小块内存。由于 WinDbg 不了解这些内存管理器,因此将该内存声明为 Unknown。它包含但不限于 .NET 的托管堆。在我的案例中,这个值是 3.98GB,比监控工具报告的 1.5GB 内存使用量大得多。我将在后面解释为什么会这样。
    • Image:是为二进制程序集分配的内存空间。
    • Stack:堆栈,比较直观。
  • 第二部分按内存类型显示使用情况:MEM_PRIVATEMEM_IMAGEMEM_MAPPEDMEM_PRIVATE 是进程私有的,MEM_IMAGEMEM_MAPPED 可以在多个进程之间共享。

  • 第三部分按内存状态显示使用情况:

    • MEM_COMMIT:已提交内存,指已分配并由物理内存或页面文件支持的虚拟地址空间部分。进程可以主动使用和访问已提交内存。
    • MEM_RESERVE:对应保留内存。保留内存指已在虚拟地址空间中保留但尚未提交的内存。保留地址空间是为了确保在需要时可用,稍后可以提交。
    • MEM_FREE:表示空闲内存。

在我的案例中,已提交内存为 1.528GB,与监控的内存使用量完全匹配。这完美解释了前面关于 3.98GB Unknown 内存段的困惑。原来这些内存大部分只是保留状态。那么下一步是获取各种类型的实际已提交内存使用量。如何实现?

借助强大的 address 扩展,我可以通过添加一些过滤器来实现:

!address -f:MEM_COMMIT -c:".echo %1 %3 %5 %7"

该命令将输出所有已提交的内存区域,并为每个区域打印其基地址 (%1)、区域大小 (%3)、状态 (%5) 和类型 (%7)。输出示例如下:

0x5f3a0000 0x1000 MEM_COMMIT Image
0x5f3a1000 0x2ab000 MEM_COMMIT Image
...
0x4580ca7000 0x5000 MEM_COMMIT Stack
...(省略数千行)

我编写了一个简单的脚本来解析上述输出,最终得到以下结果:

Memory by Type:
type Image:  233.52344MB
type Other:  1.8007812MB
type Stack:  3.796875MB
type TEB  :  0.71875MB
type PEB  :  0.00390625MB
type Heap :  84.88281MB
type <unknown> :  1240.2461MB

基于此结果,你可以看到大部分内存来自 unknown 段。我怀疑内存泄漏问题就发生在这个有问题的服务中。各种 WinDbg 命令都可以诊断内存泄漏问题。在这个特定案例中,我发现 !finalizequeue 命令非常有帮助!

在展示该命令的输出之前,让我们先了解什么是 终结器队列。在 C# 中,终结器(也称为析构函数)用于在 GC 回收类实例时执行任何必要的最终清理。

class Car
{
    ~Car()  // 终结器
    {
        // 清理语句...
    }
}

当你的应用程序封装了非托管资源(如窗口、文件和网络连接)时,应该使用终结器来释放这些资源。

带有终结器的类的对象不能立即被移除:它们会进入**终结器队列**,并在终结器运行后从内存中移除。

基于此,让我们检查 `!finalizequeue` 命令的输出:

```text
0:000> !FinalizeQueue
SyncBlocks to be cleaned up: 0
Free-Threaded Interfaces to be released: 0
MTA Interfaces to be released: 0
STA Interfaces to be released: 0
----------------------------
generation 0 has 66 finalizable objects (0000021c729c47b0->0000021c729c49c0)
generation 1 has 4 finalizable objects (0000021c729c4790->0000021c729c47b0)
generation 2 has 4372 finalizable objects (0000021c729bbef0->0000021c729c4790)
Ready for finalization 0 objects (0000021c729c49c0->0000021c729c49c0)

Statistics for all finalizable objects (including all objects ready for finalization):
MT    Count    TotalSize Class Name
00007ffcde443f78        1           32 Microsoft.Win32.SafeHandles.SafePEFileHandle
00007ffcde4407f8        1           32 System.Security.Cryptography.X509Certificates.SafeCertContextHandle
...(省略多行输出)...
00007ffcde447db0       97         3104 Microsoft.Win32.SafeHandles.SafeWaitHandle
00007ffcdd272468       28         4928 System.Diagnostics.PerformanceCounter
00007ffcde45d6a8       64         6144 System.Threading.Thread
00007ffcde4297c0     3639       262008 System.Reflection.Emit.DynamicResolver

Total 4442 objects

你可以注意到有 3639System.Reflection.Emit.DynamicResolver 类型的对象。我们知道,终结器队列中的对象在终结器运行之前无法被移除。这也意味着它们引用的任何对象,以及被这些对象引用的对象等,都必须保留在内存中。

这就是内存泄漏问题的潜在原因。System.Reflection.Emit 是一个用于动态生成 Microsoft 中间语言 (MSIL) 的低级库,我的应用程序并不依赖它。最终发现,问题源自 Service Fabric SDK,升级到新版本可以解决该问题。

总结

在本文中,我们探讨了 .NET GC 的工作原理,以及如何使用 WinDbg 诊断内存泄漏问题。 CSD0tFqvECLokhw9aBeRquyHwDysnFAOYItlzrfa8vuNoCEGevbHnPUV9QYNU+3sqzfTFm++6RhjxcOQW5te34f8oFA8zQZgcE+Xuodrr6pqYukrEkvVwQJnm1mFjWXRJp3qsFBRFO+Sp0oTyDBiBLZzxB9d37hAJHunknPW39I=