.NET内存分析的历险记

可疑趋势

不久前,我的团队发现我们的监控中与我们一项服务有关的可疑之处:

传统上,该服务的内存使用情况是稳定的,使用了大约30%的可用内存。 但是它开始使用的不仅更多,而且还在稳步增加。

我们一直关注着它,并且它还在不断发展。 绝对是内存泄漏

因此,在重新部署以将内存使用率重置回可接受的水平之后,我们开始进行调查。

与探查器的对话

我们使用内存分析器随时间拍摄服务内存的快照,并开始寻找可能导致泄漏的线索。 这可以通过与探查器的虚构对话来总结。

人力 :也许我们不取消订阅活动?
Profiler :不,随着时间的推移,事件处理程序没有增长。
:天哪!

:好吧,也许我们坚持使用对象,以防止它们被垃圾收集。
探查器 :不,对象的实例数会随着时间的推移保持相对稳定。
:天哪!

:是否需要确定对象?
Profiler :不
:天哪!

:好吧,大对象堆是否严重碎片化?
Profiler :是的…
:哇! 开始进行优化以减少内存碎片
Profiler :但是…
人力资源 :很抱歉,您正在忙于优化!
探查器 :…与内存的整体增长相比,碎片浪费的空间很小。
:天哪!

此时,开始很明显泄漏是在本机内存中,即.NET垃圾收集器无法管理的位。 我们开始失去希望了。

但是后来我们偶然发现了一篇有趣的文章。

XML陷阱

该文章建议我们对XML序列化进行看似无害的更改可能会导致泄漏。 具体来说,是构造System.Xml.XmlSerializer类的实例的方式。

原始代码是这样的:

  var serializer = new XmlSerializer(typeof(SomeType)); 
var someObject = serializer.Deserialize(someXmlString);

但这已更改为使用其他构造函数,因为我们需要覆盖默认行为:

  var serializer = new XmlSerializer( 
typeof(SomeType),
新的XmlRootAttribute
{
ElementName =“ customRootElement”
});
var someObject = serializer.Deserialize(someXmlString);

在后台, XmlSerializer即时生成一些代码,以有效地将XML反序列化为所需类型的对象。 在我们最初调用的构造函数中,此代码仅生成一次并缓存,因此可用于后续调用。 但是对于我们开始使用的其他构造函数, 它是每次生成的,并且不会被缓存

生成的代码存在于程序集中,而程序集本身不会被垃圾收集。 因此,对于每次对构造函数的调用,您最终都将使用其中一个程序集。

我们的服务使用.NET 4,通过查看Microsoft的XmlSerializer参考源,您可以看到该代码支持了这一理论:

此构造函数缓存程序集:

  public XmlSerializer(Type type,string defaultNamespace){ 

// ...

tempAssembly = cache [defaultNamespace,type];
如果(tempAssembly == null){

//(生成程序集并将其放入缓存中)
  } 

// ...
  } 

…但是其他人没有:

 公共XmlSerializer(XmlTypeMapping xmlTypeMapping){ 
tempAssembly = GenerateTempAssembly(xmlTypeMapping);
this.mapping = xmlTypeMapping;
}

(尽管我没有使用过,但在.NET Core中编写等效代码时似乎是用相同的方式编写的)。

该行为记录在XmlSerializer类的MSDN页面中,但很容易错过(至少这是我们的借口!)。

公平地说,进一步考虑它,这确实有意义,为什么它会这样工作。 在非缓存构造函数中,可以根据给定参数的值来更改XML反序列化的方式,因此在这些情况下, XmlSerializer不能仅仅因为类型相同而安全地重用动态程序集。

一切顺利

我们更改了代码,以便创建一个static实例,而不是为每个调用创建XmlSerializer ,例如:

 私人静态只读XmlSerializer序列化器=新... 

然后我们重新使用了:

  var someObject = Serializer.Deserialize(someXmlString); 

并且确定内存泄漏已消失。

得到教训

尽管这是令人沮丧的体验,但还是有一些好处:

最佳化
随着我们对.NET内存管理的更多了解,我们能够进行一些优化,以减少服务使用的内存总量,并在某些地方缩短执行时间。

更快的反馈
通常,在自动化测试中,更高级别的测试更切合实际,但也有缺点:测试速度较慢,较脆弱,并且故障更难诊断。 将较高级别的测试与较低级别的测试相结合可以帮助实现现实与快速,可行的反馈之间的平衡。

尽管我们采用这种方法进行常规应用程序测试,但是在测量内存使用情况时,我们仅依赖于高级负载测试,这与我们的服务套件相违背。 此负载测试以前帮助我们捕获了性能问题,但我们不会在每次提交时都运行它(因此,为什么直到生产之前才发现泄漏)。

因此,我们介绍了一个较低级别的测试。 这会通过服务代码的一部分重复运行方案,并且如果内存使用量显着增加,将失败。 这要快得多,因此可以作为CI管道的一部分在每次提交时运行。 而且尽管它不太现实(因为它不能覆盖我们的整个堆栈),但它确实包含了最经常更改的代码。

CLR MD
有一些很棒的分析工具可以简化分析,但是有时您需要更深入地研究。 在过去,这意味着要使用WinDbg,该功能非常强大,但配置和使用起来可能很棘手。

在此调查过程中,我们能够尝试其他方法:Microsoft的CLR MD库。 这样,您就可以通过.NET代码执行与WinDbg相同的操作。 您可以通过快照(也称为转储)或附加到运行的应用程序进行分析。 对我们来说,主要好处是:

  • 能够使用我们已经了解的语言(C#)进行实际分析时,学习曲线不会那么陡峭。
  • 该API非常易于理解,因此可以很直接地编写非常具体的查询。
  • 可以使用多个快照详细了解(例如,回答问题“拍摄第二张快照时,第一张快照中存在的特定对象是否仍在内存中吗?”)。

但是,等等,还有更多!

如果您有兴趣,可以在网上找到很多有用的资源,以帮助您了解有关.NET中的内存管理的更多信息。 以下是一些我发现有用的信息:

  • 垃圾收集基础
    一个不错的起点:
    https://docs.microsoft.com/zh-cn/dotnet/standard/garbage-collection/fundamentals
  • 垃圾收集器的基本知识和性能提示
    https://msdn.microsoft.com/zh-CN/library/ms973837.aspx
  • .NET内存管理的内幕
    一次很棒的深度学习
    https://www.red-gate.com/library/under-the-hood-of-net-memory-management