使用WinDbg诊断.NET应用内存泄漏:实战教程

0 阅读9分钟

使用WinDbg诊断.NET应用内存泄漏:实战教程

引言

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

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

背景

首先让我解释一下背景:出问题的.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内存管理器会为您完成这项任务。当程序创建新对象时,内存管理器会为您分配内存。很简单,对吧?但这只是内存管理器的 trivial 部分,复杂的任务在于内存管理器如何以及何时决定回收内存。正因如此,.NET内存管理器有另一个名字:.NET垃圾回收器

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

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

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

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

GC算法是一个复杂的话题,以上描述仅触及表面。

使用WinDbg诊断内存泄漏

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_PRIVATEMEM_IMAGEMEM_MAPPED

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

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

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

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

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

该命令输出所有已提交内存区域。我编写了一个简单的脚本来解析输出,最终得到以下结果:

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命令非常有帮助!

使用!finalizequeue定位问题

在C#中,终结器用于在GC收集类实例时执行必要的最终清理。

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

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

基于此,让我们检查!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是一个低级库,用于动态生成微软中间语言(MSIL),而我的应用程序并不依赖它。最终发现,问题来自Service Fabric SDK,升级到新版本可以解决此问题。

总结

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