65.9K
CodeProject 正在变化。 阅读更多。
Home

避免大型数据处理应用程序中第三方组件的陷阱

starIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

1.00/5 (1投票)

2013 年 10 月 4 日

CPOL

9分钟阅读

viewsIcon

23817

澳大利亚国防承包商的软件工程师如何追踪内存泄漏并提高处理高速数据可视化复杂应用程序的性能。

避免大型数据处理应用程序中第三方组件的陷阱

作为澳大利亚国防承包商的一名软件工程师,我在多个平台上从事各种项目——从部署在水下处理节点上的C++/ASM信号处理,到用于实时舰载系统的C#/WPF高速数据可视化。

我团队目前正在进行的项目涉及将我们于2008年用Borland C++编写的核心声学分析工具更新为使用C#/WPF。该应用程序读取水下记录的多通道声学数据,应用频率特征校正曲线,并在LOFAR中进行可视化。

 

该应用程序近乎实时地工作,允许操作员在记录段完成后立即分析一个声学数据通道。分析过程完成得越快,处理结果就能越快地提供给客户。

每个声学数据通道的大小可达1GB。转向更新的开发架构使我们能够迁移到64位,并在各种处理阶段将大量数据缓存在内存中。这极大地提高了应用程序的响应能力,并允许进行以前不可用的处理工作流。

初始开发阶段侧重于核心处理窗口(上图所示),这也是应用程序中执行所有繁重计算工作的部件。

在对一些真实世界数据集进行测试时,我注意到,在放大LOFAR或更改任何需要重绘LOFAR的处理参数时,应用程序的内存占用量会增加。经过多次放大/处理更改后,应用程序的速度也会明显变慢。

LOFAR的生成是性能密集型的,它涉及对声学数据进行FFT处理,应用校准传递函数,然后进行抽取以显示在UI位图中。抽取步骤是确保从原始FFT分辨率数据(高达24000x48000像素)到显示的WriteableBitmap分辨率(通常是总桌面分辨率的三分之二)的转换中不丢失视觉信息。

FFT处理和抽取例程使用第三方C/汇编信号处理库对大型数组(托管和非托管)执行信号处理。此外,在此过程中还会创建和销毁几个中等大小的非托管数组。

我曾以为问题就出在这里——我们每次都没有释放非托管内存。然而,一些简单的调试表明并非如此。在C#中处理非托管内存的好处是,你可以控制并在何处以及何时分配/释放它!

由于我对.NET和托管内存相对不熟悉,我接着认为我遇到了垃圾回收问题:所有非托管内存都已清理,我可以看到所有大型缓存数组都已适当地重置,并且对它们的引用并未存储在任何地方。然而,我那个可爱的GC.Collect()技巧对问题几乎没有影响。

我能想到的唯一其他潜在问题是意外的残留引用,例如订阅了事件但**未**取消订阅,或者将对象引用存储在列表中但未在不再需要时移除。因此,开始了一场对代码库的手动搜索……

在Visual Studio中大量使用“查找所有”,搜索“+=”、“Add”、“AddRange”等。手动搜索非常详尽、耗时,但没有发现任何明显问题。我感到沮丧和恼火,因为我花了这么多时间却离找到解决方案越来越远,这时我想,“去他妈的——也许我应该试试内存分析工具,看看它能找到什么。”

Google搜索给了我几个内存分析工具的选项。由于我之前使用过Red Gate的.NET Reflector,并发现它是一个很棒的工具,所以我决定试试他们的内存分析器。

ANTS Memory Profiler安装得很顺利。我以前从未使用过内存分析器,但我发现它非常直观。我只是在Visual Studio的ANTS菜单中选择了“Profile Memory”。

在应用程序运行时,我加载了一段声学数据。我拍摄了一个基线内存快照,然后进行了多次LOFAR显示放大和缩小操作,在此期间拍摄了其他内存快照。虽然快照的记录花费了合理的时间,但在分析过程中我没有注意到应用程序性能有任何差异。在进行了足以导致大量(500MB+)内存泄漏的放大/缩小迭代后,我停止了分析并加载了报告。

报告顶部有一个图表,详细说明了应用程序随时间的内存使用情况,以及拍摄内存快照的时间点。

我选择了快照2,这是在初始加载声学数据并填充LOFAR后拍摄的。左下角的“Largest classes”(最大类)饼图显示,大部分数据是Int16[](声学数据)和Single[](FFT数据)类型,这符合预期。

 

然后我选择了快照6。它显示声学数据和FFT数据的大小保持不变,这令人宽慰。但是,在这两个快照之间,是什么占用了额外的400MB内存?“Largest classes”饼图给出了一些线索,类型为EffectiveValueEntry[]、Gripper、Thumb和Canvas的类单独占用了200MB的额外内存!

 

为了进一步调查,我设置了快照5作为基线,快照6作为当前。这一部分包括了一系列重复的缩放操作,选择“Class List”(类列表)按钮显示了大量有趣的信息。

 

由于我担心内存使用量,我按“Size Diff”(大小差异)列排序,这显示了一个我不熟悉的`EffectiveValueEntry`对象。

我比较了系列缩放操作之前和之后的两个检查点。令我惊讶的是,这表明在短时间内,竟然构建了大约350万个对象!仔细查看创建的单个类的数量,我看到一个共同的数字约为108,160,而其他对象的数量是这个数字的倍数。这表明它们都相关。

而跳出的类名是?Visiblox.Charts.Gripper。Visiblox是我们用于可视化多个数据集的第三方图表工具包。那么,为什么这个类的使用会泄漏这么多内存,以及为什么会创建这么多实例?

我选择了Visiblox.Charts.Gripper条目,并使用了Instance Categorizer(实例分类器)工具。加载后,它显示了Gripper类实例与其他所有类之间的引用路径,并立即显示了LofarViewModelVisiblox.Charts.RectangleAnnotation类之间的联系。此时,我对可能导致问题的原因已经有了大致了解——在我漫长(数小时)的手动搜索过程中,我曾瞥过一眼。

 

然后我选择了Instance Retention Graph(实例保留图),它沿着方法调用跟踪了选定对象(我随机选择的一个)的引用路径,并证实了我的猜测。

 

当SelectionModel中的SelectionDurationChanged事件触发时,表示所选区域顶部的topSelectionRectangle(以及其他)被赋予了一个新的点,反映了所选内容的变化。

topSelectionRectangle.Points[0] = new DataPoint<double, double>(XRange.Minimum, YRange.Minimum);
topSelectionRectangle.Points[1] = new DataPoint<double, double>(XRange.Maximum, GetTopSelectedYValue());
bottomSelectionRectangle.Points[0] = new DataPoint<double, double>(XRange.Minimum, GetBottomSelectedYValue());
bottomSelectionRectangle.Points[1] = new DataPoint<double, double>(XRange.Maximum, YRange.Maximum);

分配给selectionRectangles的数据点,这些数据点最终从Visiblox图表中绑定,是新建的。很可能图表会注册到一个PropertyChanged事件,或者存储一个对DataPoint的引用。

为了验证我的理论,我将上述代码段替换为

topSelectionRectangle.Points[0].X = XRange.Minimum;
topSelectionRectangle.Points[0].Y = YRange.Minimum;
topSelectionRectangle.Points[1].X = XRange.Maximum;
topSelectionRectangle.Points[1].Y = GetTopSelectedYValue();

bottomSelectionRectangle.Points[0].X = XRange.Minimum;
bottomSelectionRectangle.Points[0].Y = GetBottomSelectedYValue();
bottomSelectionRectangle.Points[1].X = XRange.Maximum;
bottomSelectionRectangle.Points[1].Y = YRange.Maximum;

令人惊讶的是,内存泄漏问题得到了解决!然而,我仍然想知道如何创建了108,160个这样的对象。这就像SelectionDurationChanged事件被触发了很多很多次。

事实证明,确实如此。而**ANTS Memory Profiler**甚至在我没有意识到的时候就显示了在哪里。在LofarViewModel中,SelectionDurationChanged事件在LOFAR的每一行生成时都会被调用。考虑到放大/缩小操作的次数,这意味着它被调用了大约5000次!

从这个位置触发事件是一个bug,在开发早期作为一种“双保险”的方法插入,以确保重绘时选择区域有效。然而,后来重绘的情况得到了妥善处理,但**未**移除有问题的行!

至此,在不到一小时的时间内就发现了并修复了两个难以理解的bug。最重要的是,应用程序现在重绘LOFAR的速度比以前快了十倍。

那么,这次经历教会了我什么?

  1. 小心第三方库。它们可能具有您不知道的隐含的预期行为。在本例中,我们很幸运有一个bug,比正常情况快得多地暴露了这个问题。
  2. 在C#/WPF的世界中,引用被随意传递,您必须确保它们不会被意外地存储在任何可能保留它们并阻止对象被垃圾回收的地方。这包括WPF绑定、在不再需要时取消订阅事件处理程序,以及在不再需要时从集合中移除项。
  3. 定期监控应用程序的关键统计数据并分析代码。我很惊讶没有人注意到或质疑为什么应用程序内存使用量在不断攀升。也许我特定的测试场景是唯一一个能给这个特定故障情况带来压力的。无论如何,密切关注资源利用情况并定期进行分析,也许在里程碑发布之前,以确保应用程序运行精简且没有内存泄漏,这是值得推荐的。
  4. 一个专业的分析工具节省了时间。尽管我对要查找的问题类型有了一定的了解,但由于这是与第三方库的交互,所以我无法手动深入其代码来定位故障。

我花了四个小时手动验证我们所有的集合都没有保留引用,并且所有订阅的事件/消息都已正确取消订阅。诸如Process Explorer之类的工具对于说“是的,你有一个问题”非常有用,但却无法指出具体区域。考虑到开发时间的平均小时成本,您只需要**ANTS Memory Profiler**总共节省您几个小时,就能获得可观的投资回报。

我确信这不会是一个孤立的案例。我们是软件工程师:写bug是我们工作的一部分。我们只需要武装自己,用正确的工具来确保它们能够被快速找到和修复。

© . All rights reserved.