使用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分析该转储文件。本文不涉及ProcDump的使用,留给你自行学习。
接下来,让我们操作转储文件吧!开始!
诊断内存泄漏
首先,使用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(空闲):是可以从操作系统申请的整个虚拟内存。这可能包括交换空间,而不仅仅是物理RAM。对于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未知内存段的困惑点。事实证明,这些内存大部分只是被保留而已。那么下一步是获取各种类型的实际已提交内存使用量。如何实现?
感谢强大的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
0x5f64c000 0xb000 MEM_COMMIT Image
0x5f657000 0x1b000 MEM_COMMIT Image
0x5f672000 0x2000 MEM_COMMIT Image
0x5f674000 0x2d000 MEM_COMMIT Image
0x7ffe0000 0x1000 MEM_COMMIT Other
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的输出:
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=