优化应用性能和内存流量
众所周知,内存流量对应用性能有很大影响:流量越高,应用越慢。 问题不在于应用程序分配内存的频率(从性能角度来看,这几乎是免费的),而在于应用程序如何回收不再需要的内存。 执行此操作的垃圾回收(GC)机制的便利性,不幸的是,也有其代价。
首先,GC 本身需要一些 CPU 时间。 例如,其一个阶段是检测未使用的对象,这是一项涉及构建对象引用图的复杂操作。 其次,为了执行 Gen0 和 Gen1 GC*,垃圾回收器必须获得对托管堆部分的独占访问权限。 这反过来会挂起所有托管线程,除了触发“阻塞 GC”的线程。由于用户界面线程也会被挂起,用户可能会在这些时刻体验到界面卡顿。
这就是为什么您应该始终尝试优化应用程序以减少内存流量并最大限度地降低 GC 对应用响应性的影响。
在本教程中,我们将学习如何使用时间线分析来检测过多的 GC 及其原因。
示例应用程序
我们使用与 入门时间线分析教程中相同的基本示例应用程序。 此应用程序用于反转文本文件中的行,例如 ABC => CBA。
该应用程序的源代码可在 github上找到。

通过 选择文件 按钮,用户可以选择要处理的文本文件。 处理文件 按钮运行一个单独的 BackgroundWorker 线程(名为 文件处理 ),用于反转文件中的行。 主窗口左下角的标签显示进度。 处理完成后,标签显示 所有文件均已成功处理。
设想以下场景:在测试应用程序时,您发现文本文件的处理速度不如预期。 此外,在某些情况下,您在文件处理期间会遇到轻微的卡顿。
让我们使用时间线分析来分析这些性能问题吧!
步骤 1。 运行分析器并获取快照
在 Visual Studio 中打开 MassFileProcessing.sln 解决方案。
通过选择 运行分析器。
在 分析类型 中,选择 时间线。

点击 运行。 dotTrace 将运行我们的应用程序,并显示一个用于控制分析过程的特殊控制器窗口。

现在,让我们尝试在应用程序中重现性能问题。
点击 选择文件 并选择应用程序 文本文件 文件夹中附带的五个文本文件。

点击 处理文件 开始文件处理。
处理完成后,通过点击控制器窗口中的 获取快照并等待 收集时间线分析快照。 快照将在 Visual Studio 中的单独 性能分析器 工具窗口中打开。
关闭应用程序。 这也会关闭控制器窗口。
步骤 2。 开始分析
在 性能分析器 工具窗口中,点击 显示线程。 这将在单独的 线程 工具窗口中打开一个应用程序线程列表。

您可以看到 dotTrace 检测到了几个托管线程。 这些是 主要 线程、用于执行后台 GC 的 垃圾收集 线程,以及用于处理文件的 文件处理 线程*。 此外,还有两个不工作的线程: 终结器 线程和一个辅助 线程池。
由于我们关注的是文件处理缓慢的问题,让我们放大 文件处理 线程处理文件的时间段。 为此,请在 线程 图表上使用鼠标滚轮。

这会自动按可见时间间隔(1950 毫秒 )添加过滤器。 注意此过滤器如何影响其他过滤器:所有值都会根据可见时间范围重新计算。 现在应用于快照数据的过滤器是 “选择所有线程在可见时间范围内的所有时间间隔”。 让我们记住处理文件大约需要 2 秒。
在 线程 窗口顶部的进程概览图表中,查看 垃圾回收 条。 看起来在文件处理期间执行了许多阻塞 GC。 这表明存在大量内存流量,毫无疑问,这会影响应用程序性能。
现在,让我们找出这些流量背后的真正原因。 有两种方法*可以做到这一点:
识别在 GC 期间运行的线程和方法。 这些必须是触发这些回收的线程和方法。
识别分配了大部分内存的方法。 逻辑很简单:触发 GC 的主要原因是由于内存分配而调整堆的大小。 因此,如果一个方法分配了大量内存,它也会频繁触发 GC。
提前说明,我们可以说第二种方法更简单且稍微更可靠。 然而,出于教育目的,让我们尝试两种方法。
步骤 3。 内存流量从哪里来? 分析垃圾回收
在 线程 窗口(或 性能分析器 窗口)顶部的过滤器列表中,选择 阻塞 GC 过滤器中的值 阻塞 GC。 生成的过滤器将是 “选择所有显示线程在可见时间范围内发生阻塞 GC 的所有时间间隔”。

现在,让我们确定发生 GC 的线程。* 例如,我们可以假设这是 主要 线程。
在 线程 图表上选择 主要 线程。 生成的过滤器现在是 “选择主线程在可见时间范围内发生阻塞 GC 的所有时间间隔”。

打开并查看 线程状态 过滤器。 现在,它显示了主线程在所有这些 GC 期间的操作。 大部分时间(97.3% )它是 正在等待。 这意味着 GC 发生在其他线程上(显然是在 文件处理 线程上),主线程必须等待 GC 完成。

现在,让我们找出 文件处理 线程中导致所有这些 GC 的方法。
移除主线程的过滤器。 为此,请清除线程列表中相应的复选框。
相反,在列表中选择 文件处理 线程。 生成的过滤器现在是 “选择 FileProcessing 线程在可见时间范围内发生阻塞 GC 的所有时间间隔”。

查看 线程状态 过滤器。 它显示了 99.1% 的 GC 时间 FileProcessing 线程处于 正在运行 状态。 这证实了该线程是这些 GC 的责任所在。

在 线程状态 中选择 正在运行。
在 性能分析器 工具窗口中,点击 热点 以查看用户方法中自有时间最高的列表。

考虑到所有应用的过滤器, 热点 现在显示了触发 GC 的顶级方法*。 正如您所见,
StringReverser.Reverse是可能在应用程序中生成最多内存流量的方法。
步骤 3。 内存流量从哪里来? 分析内存分配
现在让我们尝试一种更简单的分析内存流量的方法。 如前所述,思路是识别分配最大内存量的方法。
通过点击
按钮移除所有过滤器,无论是在 性能分析器 还是 线程 工具窗口中。在 事件 过滤器中,选择 内存分配*。

查看 线程 图表。 文件处理 线程分配了大量内存——5882 MB。 毫无疑问,它是我们应用程序中高内存流量的罪魁祸首。
查看 热点。 现在,它按分配的内存量对方法进行排序。
StringReverser.Reverse方法以 5496 MB 遥遥领先。
步骤 4。 优化代码
让我们查看方法的代码并尝试找出问题所在。
右键点击
StringReverser.Reverse方法,在 热点 中选择 导航到代码。
查看代码。 看起来此方法用于反转文本文件中的行。 它接收一个字符串作为输入并返回反转后的字符串。
public string Reverse(string line) { char[] charArray = line.ToCharArray(); string stringResult = null; for (int i = charArray.Length; i > 0; i--) { stringResult += charArray[i - 1]; } return stringResult; }显然,问题出在它反转字符串的方式上。 问题在于字符串是不可变类型——其内容在创建后无法更改。 这意味着每次通过
+=操作向stringResult添加字符时,都会在内存中分配一个新字符串。 使用一个特殊的StringBuilder类或将字符串作为char数组处理并使用Reverse方法会更有效。 让我们尝试第二种选项。按照如下所示更正
StringReverser.Reverse方法。public string Reverse(string line) { char[] charArray = line.ToCharArray(); Array.Reverse(charArray); return new string(charArray); }
步骤 5。 验证修复
重新构建解决方案并按照 步骤 1中描述的方式再次执行分析。
快照打开后,将 事件 切换到 内存分配。
查看 线程 图表。 我们的改进起作用了! 文件处理 线程的内存流量已从接近 6 GB 减少到 166 MB。

如果您放大 文件处理 处理文件的时间间隔,您会看到文件处理速度显著加快。 现在,处理所有文件仅需约 300 毫秒,而修复前需要 2 秒。