使用 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(如 malloc 和 free)来管理内存。但在 .NET 框架上,你的工作会更轻松,因为 .NET 内存管理器会为你完成这项任务。当程序创建新对象时,内存管理器会自动分配内存。很简单,对吧?但这只是内存管理器的一小部分,复杂的任务在于内存管理器如何以及何时决定回收内存。正因如此,.NET 内存管理器还有另一个名字:.NET 垃圾回收器 (GC)。
.NET GC 使用两个 Win32 函数:VirtualAlloc 用于分配内存段,VirtualFree 用于将内存段释放回操作系统。整个过程可以分为以下三个阶段:
-
标记阶段:在此阶段创建存活对象列表。这从一组称为 GC 根 的引用开始。GC 将这些根对象标记为存活,然后查看它们引用的任何对象,并将这些对象也标记为存活。GC 继续迭代搜索。这个过程也称为可达性分析。所有不在可达列表中的对象都可能从内存中释放。
-
重定位阶段:更新所有可达对象的引用,使其指向新位置。重定位阶段的目的是通过将存活对象压缩得更紧密来优化内存使用。这有助于减少内存碎片,提高内存分配效率。
-
压缩阶段:释放死对象占用的内存空间,并将存活对象移动到新位置。
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_PRIVATE、MEM_IMAGE和MEM_MAPPED。MEM_PRIVATE是进程私有的,MEM_IMAGE和MEM_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
你可以注意到有 3639 个 System.Reflection.Emit.DynamicResolver 类型的对象。我们知道,终结器队列中的对象在终结器运行之前无法被移除。这也意味着它们引用的任何对象,以及被这些对象引用的对象等,都必须保留在内存中。
这就是内存泄漏问题的潜在原因。System.Reflection.Emit 是一个用于动态生成 Microsoft 中间语言 (MSIL) 的低级库,我的应用程序并不依赖它。最终发现,问题源自 Service Fabric SDK,升级到新版本可以解决该问题。
总结
在本文中,我们探讨了 .NET GC 的工作原理,以及如何使用 WinDbg 诊断内存泄漏问题。 CSD0tFqvECLokhw9aBeRquyHwDysnFAOYItlzrfa8vuNoCEGevbHnPUV9QYNU+3sqzfTFm++6RhjxcOQW5te34f8oFA8zQZgcE+Xuodrr6pqYukrEkvVwQJnm1mFjWXRJp3qsFBRFO+Sp0oTyDBiBLZzxB9d37hAJHunknPW39I=