在 Chromium 等中查找 Windows HANDLE 泄漏

2021-07-26 10:41:40

三年前,我发现 CcmExec.exe 未能关闭进程句柄导致 32 GB 内存泄漏。该错误已修复,但从那时起,我启用了 Windows 任务管理器中的句柄列,以防万一我遇到另一个句柄泄漏。由于这项例行检查,我在 2021 年 2 月注意到,Chrome 的一个进程打开了 20,000 多个句柄!这个 Chrome 错误现已修复,但我想分享如何调查处理泄漏,因为还有其他泄漏程序。我也想分享我的学习过程。对 Chrome 的任务管理器进行的一些调查表明,有问题的进程是 gmail 的渲染器进程,再多看看就会发现大多数 Chrome 渲染器进程的内核句柄少于 1,000 个。 20,000 似乎异常,经过几个小时的监控后,我可以看到句柄数量无限增加。怜悯活着,看起来我们的句柄泄漏了。我想知道的第一件事是这些是什么类型的手柄。 Windows 句柄可以引用文件、进程、线程、事件、信号量和许多其他内核对象。我转向 sysinternals 的句柄工具查看它是哪种类型,但它说在这个过程中只有几百个句柄打开。事实证明,handle.exe 默认只显示有关文件句柄的信息。要获取完整信息,您可以传递 -a 以转储有关所有句柄的信息,或传递 -s 以按类型进行汇总。 -s 选项对本次调查最有帮助。典型的输出像这样寻找 gmail(从管理命令提示符运行它会解析 <Unknown type> 句柄):句柄类型摘要:<Unknown type> : 4 <Unknown type> : 77 <Unknown type> : 48 ALPC Port :1 目录:2 事件:20858 文件:147 IoCompletion:4 IRTimer:6 键:8 信号量:8 线程:31 TpWorkerFactory:3 WaitCompletionPacket:10 总句柄:21207

所以。事件处理它是。还可能会更糟糕的。泄漏的进程句柄保留了昂贵的内核结构,每个句柄似乎加起来大约 64 KB。相比之下,事件句柄相当便宜——可能只有 16 个字节左右(很难衡量)。这次泄漏的影响可能微乎其微。但是,Chrome 使用 RAII 对象来管理它的所有句柄,所以我们不应该有泄漏——我想知道出了什么问题。经过一番询问,我发现我的同事都不知道如何调查处理泄漏,所以我不得不弄清楚。我将调试器附加到 gmail 进程并尝试在创建事件的函数上设置断点,但我收到太多噪音。正在创建数百个事件,其中超过 99% 的事件被彻底删除。我需要更好的东西。有些人可能会说我太沉迷于使用 Windows 的事件跟踪来尝试解决所有问题,但只要它继续工作,我就会继续使用它。我怀疑 ETW 可能能够跟踪句柄创建和销毁并找到泄漏,但我不知道如何。一点谷歌搜索找到了这篇文章。它让我开始了,但它有一些缺陷: 文章显示数十个标志被传递给 xperf.exe,但没有解释它们是什么 指定的标志导致非常高的数据速率,这使得长时间运行的跟踪不切实际帖子承诺会在“下一个帖子”中解释如何分析数据,但没有“下一个帖子”和求助电话导致没有任何漏洞,这让我破案了。我开始主要按原样使用它。我编写了一个泄漏 10,000 个句柄的测试程序,并记录了一个跟踪,看看我是否可以看到故意泄漏。有效。然而,它以每小时许多 GB 的速度记录数据。因为手柄泄漏相当缓慢——泄漏 20,000 个手柄需要数周时间——我需要进行数小时的跟踪以确保我能发现泄漏。我专注于两种策略:

您可以通过查看 xperf -providers 的输出找到更多关于其中一些含义的信息,但简短的版本是推荐的命令行不仅记录有关句柄的信息,还记录有关每个上下文切换和所有磁盘的信息。 /O(请参阅延迟),启用采样分析器(也在延迟中)等等。我的第一步是将其精简为这样:需要 PROC_THREAD+LOADER 来理解任何 ETW 跟踪,而 OB_HANDLE+OB_OBJECT 似乎是句柄跟踪的关键。一位 Microsoft 联系人告诉我不需要 OB_OBJECT,因此我将其删除。推荐的命令对 -stackwalk 标志也有六个不同的参数,而我真正需要的是分配句柄时的堆栈。我删除了其中的五个,然后添加了 HandleDuplicate 以防万一。您可以在 github 上的批处理文件中看到一个功能更齐全的命令行,但这显示了最小的想法。它开始跟踪,记录足够的信息以将事件归因于正确的线程和模块 (PROC_THREAD+LOADER),记录有关句柄操作的信息 (OB_HANDLE) 并记录有关句柄创建和句柄复制的调用堆栈。完美的!然后我将(仍然太大的)跟踪加载到 WPA 中,并查看所有数据的来源。产生最多事件的进程是任务管理器和 Chrome 远程桌面。高流量意味着这两个进程正在创建和销毁大量句柄。这通常不是问题(它们没有泄漏句柄)但它使我的跟踪变大,因此解决方案很明确 - 在跟踪时关闭它们。关闭任务管理器很容易,但由于我是在家中在办公室的工作站上进行这项调查,因此关闭 Chrome 远程桌面不太方便。但是,不用担心。我确保一切正常运行,然后断开连接(以减少生成的数据量),然后在 12 小时后重新连接。这种策略 - 关闭生成过多数据的程序 - 通常对 ETW 很有用,因为它通常记录有关整个系统的信息,包括所有正在运行的进程。现在我有一些大但可管理的跟踪(~970 MB,压缩),我可以开始分析。我将跟踪加载到 Windows 性能分析器中,最终找到了句柄跟踪图。它位于 Graph Explorer 中的 Memory (???)、Handles、Outstanding Count by Process 下。完美的!打开它会给出一个图表,显示每个进程的句柄计数随着时间的推移。十二个小时内有很多活动。放大到十分钟的时间跨度显示一连串的活动。进程被创建,分配一些句柄,然后死亡,创建一个像数字纪念碑谷一样的视图。其他进程分配和释放句柄没有明确的模式,其他进程似乎无限期地增加它们的句柄数:图表很漂亮,但我决定我想要原始数字,所以我查看了表格。默认视图对于分配了近 150 万个句柄的 Chrome 浏览器进程来说非常糟糕。可是等等…

句柄泄漏不在浏览器进程中,它远不及 150 万个句柄。好吧,事实证明默认视图具有误导性。虽然图表/表格被称为“按流程计算的杰出计数”,这确实是图形显示的内容,但该表格实际上显示了按流程计算的累计计数。也就是说,浏览器进程在 12 小时内分配了 150 万个句柄,但它释放了几乎所有的句柄,那么谁在乎呢?我想看到的是突出的句柄——已分配但未释放。该表可以显示这些数据,但它肯定不会让它变得容易。在堆跟踪表中有一个标记为类型的列。现在这是一个非常糟糕的列名选择,但它是一个非常重要的列。至关重要的是,在类型列的上下文中,“内部”表示在跟踪记录的时间范围内,而“外部”表示在跟踪记录的时间范围之外。可用的类型有: AIFO 是有趣的事件,因为它们是在记录跟踪时分配的,然后在跟踪记录停止之前未释放。它们可能在几秒钟后被释放,但是如果您在同一个调用堆栈上看到足够多的它们,您就会怀疑……这就是堆跟踪的工作原理。句柄跟踪采用了这个概念并将其更改为足以令人困惑的程度。他们将列从 Type 重命名为 Lifetime。 Lifetime 绝对是一个更好的名字,但不可避免地会发生一些混淆,因为同一概念有两个名字 他们默认关闭了该列。这和堆跟踪是一致的,默认情况下Type列是关闭的,但是莫名其妙。这是表格中最重要的一列,所以我不明白为什么它不在前面和中间。除了默认情况下关闭列之外,他们实际上隐藏了它!通常,您可以右键单击任何列标题,选择更多列...,然后查看您可以启用的列列表。许多用户(我很长一段时间)可能认为菜单中的列列表是完整的。它不是。对于某些列——包括极其重要的生命周期列——您必须调用视图编辑器(Ctrl+E 或单击“按进程未完成计数”右侧的齿轮框),然后从可用列列表中拖动生命周期列在左边到右边的列表。唉……我向 WPA 负责人报告了这些问题,他们同意需要改进。既然 WPA(预览版)在 Microsoft 商店中可用,我们应该期望像这样的修复程序将比以往任何时候都更快地发布。

ETW 的句柄跟踪要注意的最后一个问题是它似乎有一些记帐错误。它将报告给定“handle -s”输出不可能发生的泄漏,因此请务必在花费太多时间之前交叉检查您的结果。带着所有的学习和发现,我得出了这个观点。它显示了 gmail 进程在 12 小时内泄漏的 527 个事件句柄,其中 510 个都泄漏在同一个调用堆栈上,以 WaitableEvent 构造函数结束:事件对象由 Chrome 的 WaitableEvent 类分配并存储在其中,由其 ScopedHandle 成员管理多变的。这意味着如果我们泄漏了这些句柄,我们也会泄漏 WaitableEvent 对象。如果我们泄漏了那些,那么我们还泄漏了什么?我使用堆快照跟踪来监控 gmail 进程。这对我来说是熟悉的领域,所以它进行得很顺利。然后我通过类似于句柄泄漏的调用堆栈寻找潜在的泄漏并找到了一些。然后我环顾四周寻找其他潜在的泄漏,其计数相似且看起来似乎相关,我发现了一个似乎与 IDBFactory 和 OperationsController 对象泄漏相关的集合。我使用 Chrome 的开发人员工具查看 gmail 是否泄漏了 IDB 相关对象。事实并非如此,所以我不能把这归咎于 gmail 团队。有几个人最终为分析做出了贡献。我了解如何解释这些痕迹,并且可以在 Chrome 的自定义检测版本上重现泄漏,但我对 JavaScript 很糟糕,而且我不了解我们的 IDB 架构。我的一位同事了解泄漏对象的架构。这位同事意识到泄漏的分配发生在服务工作线程上,并且在 IndexedDB 连接打开时只要服务工作线程消失就会泄漏。事实证明,这种情况在某些网站上经常发生,而 Chrome 对此处理得很差。那就是错误!进一步的调查支持了这一理论——泄漏对象数量的增加与服务工作线程的退出有关。然后我意识到服务工作者与 Google Drive 的离线模式相关联。如果我禁用离线模式,那么泄漏就会消失。

花了几个月的时间来理解这个错误,但很快就创建了一个修复程序(不是我 - 我仍然不理解代码的那部分)并在几天后登陆 Chromium 存储库。该修复于 5 月登陆 M92,并于 2021 年 7 月 21 日左右开始向普通用户推出。如果您还没有修复,那么您很快就会获得。一旦理解了错误,我就可以进行压力测试。使用带有 bug 的 Chrome 版本,我同时打开了 gmail、工作表、文档和驱动器。所有这些都使用离线模式,因此所有这些都泄漏了句柄。我的 Chrome 窗口看起来像这样: 在让有问题的 Chrome 像这样运行几天之后,任务管理器在按句柄排序时看起来像这样:前 11 个进程中有 5 个是 chrome.exe(四个渲染器加上浏览器进程)。更新浏览器后,四个渲染器不再显示在顶部列表中。但是其他过程呢? WPA.exe 是 Windows 性能分析器——我用来分析句柄泄漏跟踪的工具。它打开了大约 1,700 个 .symcache 文件,并且似乎有一个事件句柄泄漏,就像 Chrome 一样。我已经向开发人员报告了这些问题,他们正在调查。系统混合了事件句柄、文件句柄、IoCompletion 句柄、进程句柄、WaitCompletionPacket 句柄等。我不知道这些是否代表泄漏。我猜他们没有。此 dllhost.exe 副本托管 thumbcache.dll。一些实验表明,如果我创建新的 .mp4 文件,那么当资源管理器创建缩略图时,这个过程会泄漏每个文件 12 个以上的 WaitCompletionPacket 句柄。当我清理假期视频时,这很容易泄漏成百上千个句柄。我在推特上提到过两次。我还在反馈中心报告了它(仅对 Windows 10 上的非公司用户可见)。可以在此处找到显示我创建 20 个 .mp4 文件并泄漏数百个句柄的 ETW 跟踪。这应该是固定的。最后一个是 IntelTechnologyAccessService.exe,它会泄漏事件句柄。没有符号或不知道它的作用,我只能说泄漏来自他们的 core.dll。我在推特上发布了关于泄漏的消息,并得到了一位想要修复泄漏的英特尔开发人员的迅速回应。我在禁用离线模式和固定版本的 Chrome(重新启用离线模式)的情况下进行了相同的压力测试。这清楚地表明禁用离线模式阻止了错误的出现,并且证明了修复是有效的。句柄泄漏消失了,相关的内存泄漏也消失了。

当运行 Chrome 的固定版本时,Windows 任务管理器看起来更像这样。 Chrome 的浏览器和 GPU 进程(未显示)仍然有 1,000 多个句柄打开,因为它们的工作方式,但渲染器进程使用的句柄很少,以至于它们都不再出现在结果的第一页上——问题解决了。 dllhost.exe 的排名上升了,因为我通过创建 512 个新的 .mp4 文件对其进行了压力测试,并且资源管理器出现了,因为显然我的 .mp4 测试显示其中存在事件句柄泄漏。叹息……处理泄漏并不是最糟糕的事情。进程句柄泄漏非常昂贵,但事件句柄泄漏则不然。除了在某些情况下——比如 Chrome 的——句柄泄漏可能与其他泄漏相关,所以它们通常值得调查。注意任务管理器,尤其是你自己的进程,这样你就可以避免这个问题。我仍然不知道如何调查 GDI 句柄泄漏——如果有人知道,请告诉我。您可以在 Chromium 错误中阅读长达数月的调查——失误等等。此条目发表在错误、代码可靠性、调查报告、uiforetw、xperf 和标记句柄、泄漏。为永久链接添加书签。