原文:
Windows 的事件跟踪 (ETW,亦称为 xperf) 通常用于监控 CPU 使用情况,通过采样分析器及其记录上下文切换的能力来实现。此外,ETW 还用于监控文件 I/O、磁盘 I/O,有时还会记录注册表访问,当然还包括 GPU 活动、窗口聚焦、UI 延迟、进程生命周期等。简而言之,ETW 被用于多种不同的用途,通常通过相同的配置在整个系统中进行记录。
而堆分析则有所不同。即使是那些熟悉 ETW 跟踪记录奇技淫巧的开发人员,也可能会发现如何进行堆分析有些令人畏惧。我一直推迟写这篇文章,因为讲解如何记录堆跟踪并分析它是非常繁琐的工作。不过,随着 UIforETW 的发布,记录 ETW 堆跟踪几乎变得非常简单,分析它也变得更加轻松。另外,近期(大约在 2016/2017 年)ETW 的一些更新,使得记录堆跟踪变得更加简便。
作为一个额外的好处,系统内存列表和所有进程的 VirtualAlloc 调用都会在 UIforETW 追踪中记录,并且会在本文底部进行简单的文档说明,还可以选择记录轻量级的内存分析堆栈信息。
简而言之,ETW 堆跟踪使得记录和分析任何使用 Windows 堆的应用程序(默认的 VC++ 内存分配器)的每个分配和释放变得非常容易。这使得调查内存泄漏、分配波动以及其他堆问题成为可能。
本文在 2015 年 6 月进行了更新,因为现在支持同时对多个进程名称进行堆分析。
本文在 2018 年 10 月进行了更新,增加了新的堆跟踪(PID 和启动-跟踪)及分析选项。
请注意,如果你只想记录所有未释放的分配的大小和堆栈信息,那么 堆快照 是一个更高效的选项,可以记录数周的分配信息。它们的文档请参见 此处。
(参见 UIforETW 公告文章 获取更多详细信息)
堆跟踪的不同之处
ETW 堆跟踪不会在全局启用,因为它的开销较大。仅仅为一个进程记录每个堆分配的堆栈信息,数据量就可能巨大,而如果对所有进程都启用堆跟踪,那将是不可承受的。因此,ETW 堆跟踪是为特定进程启用的。启用的方式有三种:
- 按进程名称进行跟踪
启用堆跟踪的第一种方式(UIforETW 支持最长的方式)是通过指定要跟踪的进程名称,然后记录堆事件。这是一个两步操作。
步骤 1:在 Image File Execution Options 中为每个要跟踪的进程名称创建并设置一个 TracingFlags 注册表项,值为 '1',这样 Windows 堆就会在启动时配置自己以进行堆跟踪。正如 Image File Execution Options 中的所有选项一样,这些设置不会影响已运行的进程,只有在设置注册表键值后启动的进程才会受到影响。

步骤 2:通过“ -heap -Pids 0”指令创建一个额外的 ETW 会话。此会话将记录启动时已经设置了 TracingFlags 注册表项为 '1' 的进程的信息。
使用 UIforETW 时,你需要打开设置对话框并指定你的进程名称。默认情况下,它是 Chrome.exe,因为我最初为 Chrome 编写了 UIforETW,但你可以将其更改为任何其他进程名称。exe 后缀是必需的,且如果要指定多个进程名称,请用分号隔开:

然后,设置跟踪模式为 Heap tracing to file。一旦这样做,就会为指定的进程创建并将 TracingFlags 设置为 1。当退出 UIforETW 或更改跟踪模式时,该值会被重置为 0。这意味着,只要选择了 Heap tracing to file,你就可以启动要堆跟踪的进程。
为了实际开始记录堆跟踪信息,你需要点击 Start Tracing。

理解了这个两步操作后,你就知道如何使用它了。你需要设置 Heap-profiled process 名称,并选择 Heap tracing to file,然后启动你想要跟踪的进程。在任何数据被记录之前,你需要开始跟踪。无论你是先启动进程还是后启动进程,都决定了是否记录进程启动时的分配。
是的,这也适用于多个进程。当我分析 Chrome 时,所有 Chrome 进程的堆数据都会被记录——只要它们是在设置了注册表键之后启动的。它甚至支持多个不同的进程名称。
- 按 PID 进行跟踪
第二种启用堆跟踪的方式是指定一个或多个已经在运行的进程的 PID(最多支持两个)。在 UIforETW 设置对话框中输入一个或两个 PID,并用分号分隔,然后将堆跟踪类型设置为 Heap tracing to file,开始跟踪时就会记录指定进程的堆数据。这个方法非常简单,但有一些缺点,比如不能记录进程启动时的分配信息,并且当 PID 发生变化时需要手动调整。以下是设置对话框的示例:

- 启动并跟踪一个进程
第三种方式是通过 ETW 启动你想要跟踪的进程,以确保从该进程启动时就开始堆跟踪,而不涉及其他进程。使用这种方法时,你需要在堆跟踪进程字段中输入要启动的可执行文件的完整路径。然后,当你开始堆跟踪时,这个可执行文件将会启动并被跟踪。

分析堆跟踪
记录完场景后(最好保持短时间记录,避免生成体积庞大且难以操作的跟踪数据),你可以像往常一样保存跟踪缓冲区。跟踪的名称通常是类似“date_time_bruced_notepad_heap.etl”的格式,这有助于提醒你记录了什么数据。通常,你应该双击跟踪文件,将其加载到 Windows 性能分析器(WPA)中。
UIforETW 启动时的配置不会立即显示用于查看堆或内存数据的图表,因此你需要添加这些图表(并可选择保存为新的启动配置文件)。内存图表位于 Memory 部分的 Graph Explorer 中,最应该添加的图表可能是 Heap Allocations。拖动它后,你会看到 WPA 默认的堆查看设置,虽然通常这些设置我认为并不适合。默认视图显示了一些通常不需要的列,例如 Address 和 AllocTime,而缺少了非常重要的列,例如 Stack 和 Type。为了得到更合适的默认设置,可以选择 Randomascii Heap Analysis 配置,得到如下界面:

现在,你可以开始深入分析你的进程了。如果你想按堆进行分组,可以添加 Handle 列,但通常它的意义不大。
接下来的列是最重要且最不显眼的。在 ETW 内存分析中,有四种 Type(类型)表示的分配,它们是:
- AIFO – 分配发生在时间范围内,释放发生在时间范围外:这些是那些在显示时间范围内分配但未在该范围内释放的内存块。它们可能在时间范围外被释放,或者可能从未被释放。当你深入分析这些内存块的堆栈时,Count 表示未释放的分配,Size 表示未释放的内存。
- AOFI – 分配发生在时间范围外,释放发生在时间范围内:这些内存块的分配发生在时间范围外,释放发生在时间范围内。它们是 AIFO 的镜像,计算非释放分配的数量时,通常需要从 AIFO 中减去 AOFI。
- AOFO – 分配和释放都发生在时间范围外:这些是存活时间无限的内存块——至少在显示的时间范围内如此。
- AIFI – 分配和释放都发生在时间范围内:这些内存块是堆分析中的“飞蛾”——它们生得快,死得快,在显示的时间范围内分配和释放。
在 Type 列之后是 Stack 列,它的表现与往常相同。使用默认的 Randomascii 表格布局,你可以深入分析某种类型的分配,按你选择的列进行排序。
接着是 Count 列,它显示每行总结的分配数量。在 Heap Allocations 图表中,某进程的 Count 包括了在显示的时间范围内存活的所有分配。这个范围通常非常广,因此,除非你按 Type 分组,否则我认为 Count 列几乎无法提供有意义的解释。
Impacting Size 是“当前视口内堆使用量的变化”。这意味着,它总结了每行在显示的时间范围内,堆请求内存的总量。对于 AOFO 和 AIFI,这个值始终为零,因为 AOFO 表示在该时间范围内没有分配或释放,而 AIFI 表示在该时间范围内的分配和释放是配对的。对于 AIFO,这个数字就是分配的字节数,并且它将与 Size 一致。对于 AOFI,这个数字始终是 Size 的负值。Impacting Size 在你理解它后非常有用。即使没有使用 Type 列进行分组,它也是有意义的。
Size 列则显示了每行总结的总字节数,无论分配发生的具体时间。Size 和 Impacting Size 都是以请求的字节数衡量的,因此一字节的分配在这里显示为“1”,而不是它在堆中的实际占用空间。
其他堆图表
在进行堆跟踪时,还可以使用以下图表:
- Low Fragmentation Heap 图表揭示了一些堆的内部细节,你可以深入探究这些信息。
- Heap Extents 图表显示了进程使用的承诺内存量,可能有助于推测堆的效率等有用信息。但由于我缺乏足够的经验,无法深入分析这个图表。
其他内存图表
所有 UIforETW 图表中都记录了一些内存信息,因为 VIRT_ALLOC 和 MEM_INFO 提供者也会被记录:
- Virtual Alloc 图表显示了类似于堆图表的信息,包括具有与堆图表相同的 Type 列。通常默认设置不太好,因此可以使用 RandomAscii 配置来进行优化。需要注意的是,UIforETW 始终会记录所有进程的 VirtualAlloc 信息,因为你永远无法预知何时它会有用。当堆跟踪被记录时,每个 VirtualAlloc 调用也会记录堆栈信息,这使得分析数据更有价值。如果你在非堆跟踪时查看 VirtualAlloc 信息,则可能希望隐藏 \[empty\] Stack 列。

值得指出的是,只有从进程启动时开始跟踪,你才能用 VirtualAlloc 数据得出该进程分配了多少内存;否则,你会错过许多 VirtualAlloc 调用,从而大大低估进程的内存消耗。
- Memory Utilization 图表展示了大约每 0.5 秒采样一次的内存使用情况,显示了各种内存列表的情况,如 Active List、Zero 和 Free Lists、Standby Lists 等。有关这些列表的更多解释可以参考书籍或讲座,主要需要记住的是 Standby Lists 和 Zero 和 Free Lists 代表可用内存,如果它们过低(低于约 800 MB),Windows 将开始修剪工作集,这可能会在未来某个不确定的时间导致性能下降。

在这里,我们可以看到 Zero 和 Free Lists 被暂时耗尽以满足某些短期内存需求,但在释放后又恢复到了过量的水平。
文档 == 好
在写这篇博文时,我意识到我已经不记得我当时是如何设计 UIforETW 堆跟踪的了。下次我忘记的时候,我会直接阅读这篇文章,而不是盯着源代码看,试图搞明白是如何运作的。在回顾的过程中,我发现了一个 bug,它已经在 7c8e56d 版本中修复,并对其他细节做了一些调整。
因此,确保你获得 UIforETW 的最新版本!
参考资料
UIforETW 可在 https://github.com/google/UIforETW 上获取,预构建的 UIforETW 二进制文件可以在 发布区 中找到。
UIforETW 的公告文章请参见:https://randomascii.wordpress.com/2015/04/14/uiforetw-windows-performance-made-easier/
0