DMD 堆扫描模式

Firefox 的 DMD 堆扫描模式跟踪所有活动 malloc 分配内存块及其分配栈,并允许您将这些块以及其中存储的值记录到文件中。当与循环收集器日志记录结合使用时,这可以通过找出哪个对象持有对泄漏对象的强引用来用于调查 refcounted 循环收集对象的泄漏。

**何时使用?**DMD 堆扫描模式旨在用于调查循环收集 (CCed) 对象的泄漏。DMD 堆扫描模式是一种“最后的手段”,仅应在尝试过所有其他方法都失败后使用,除非可能是引用计数日志记录。如果您不知道是什么导致泄漏,它特别有用。如果您有一个引入泄漏的补丁,您可能最好在尝试此操作之前先审核补丁创建的所有强引用。

下面给出的具体步骤适用于泄漏对象一直存在到关闭的情况。您可以针对在关闭时消失的泄漏修改这些步骤,方法是在关闭之前收集 CC 和 DMD 日志。但是,在这种情况下,使用引用计数日志记录或带有在对泄漏对象的 Release() 调用上设置的条件断点的 rr 来查看哪个对象实际执行导致泄漏对象消失的释放操作可能更容易。

先决条件

  • Firefox 的调试 DMD 版本。此页面 描述了如何做到这一点。这可能应该是一个优化过的构建。非优化的 DMD 构建将生成更好的堆栈跟踪,但它们可能非常慢以至于毫无用处。

  • 构建将非常缓慢,因此您可能需要禁用一些关闭检查。首先,在 toolkit/components/terminator/nsTerminator.cpp 中,删除 RunWatchDog 中除对 NS_SetCurrentThreadName 的调用之外的所有内容。这将防止看门狗在关闭需要几分钟时杀死浏览器。其次,您可能需要注释掉 xpcom/build/XPCOMInit.cpp 中对 MOZ_CRASH("NSS_Shutdown failed"); 的调用,因为这似乎也会在关闭极其缓慢时触发。

  • 您需要循环收集器分析脚本 find_roots.py,可以作为 Github 上的这个 repo 的一部分下载。

生成日志

下一步是生成多个日志文件。您需要为单个运行获取一个关闭 CC 日志和一个 DMD 日志。

**定义** 我将为您的 Firefox DMD 构建的对象目录编写 $objdir,为 Firefox 源码目录的顶层编写 $srcdir,为 heapgraph repo 的位置编写 $heapgraph,以及为希望日志保存到的位置编写 $logdir$logdir 应以路径分隔符结尾。例如,~/logs/leak/

您需要运行 Firefox 的命令将如下所示

XPCOM_MEM_BLOAT_LOG=1 MOZ_CC_LOG_SHUTDOWN=1 MOZ_DISABLE_CONTENT_SANDBOX=t MOZ_CC_LOG_DIRECTORY=$logdir
MOZ_CC_LOG_PROCESS=content MOZ_CC_LOG_THREAD=main MOZ_DMD_SHUTDOWN_LOG=$logdir MOZ_DMD_LOG_PROCESS=tab ./mach run --dmd --mode=scan

分解

  • XPCOM_MEM_BLOAT_LOG=1:这报告了 XPCOM 泄漏跟踪系统创建和销毁以及跟踪的每个对象的计数列表。从此图表中,您可以看到通过关闭泄漏了多少特定类型的对象。这在以后的手动分析阶段可能派上用场,以获得支持您直觉的证据。例如,如果您认为 nsFoo 对象可能使您的泄漏对象保持活动状态,则可以使用此功能轻松查看我们是否泄漏了 nsFoo 对象。

  • MOZ_CC_LOG_SHUTDOWN=1:这在关闭期间生成一个循环收集器日志。在关闭期间创建此日志很好,因为日志中与泄漏无关的事物较少,并且禁用了各种循环收集器优化。还将创建一个垃圾收集器日志,您可能不需要它。

  • MOZ_DISABLE_CONTENT_SANDBOX=t:这会禁用内容进程沙箱,这是必需的,因为 DMD 和 CC 日志文件是由子进程直接创建的。

  • MOZ_CC_LOG_DIRECTORY=$logdir:这选择循环收集器日志保存的位置。

  • MOZ_CC_LOG_PROCESS=content MOZ_CC_LOG_THREAD=main:这些选项指定我们仅希望内容进程的主线程的 CC 日志,以使关闭速度变慢。如果您的泄漏发生在不同的进程或线程中,请更改选项,这些选项列在 xpcom/base/nsCycleCollector.cpp 中。

  • MOZ_DMD_SHUTDOWN_LOG=$logdir:此选项指定我们希望在 XPCOM 关闭的后期获取 DMD 日志,以及该日志保存的位置。与 CC 日志一样,我们希望此日志在非常后期,以避免尽可能多的非泄漏事物。

  • MOZ_DMD_LOG_PROCESS=tab:与 CC 一样,这意味着我们只希望内容进程中的这些日志,以加快关闭速度。此处允许的值与 XRE_GetProcessType() 返回的值相同,因此请根据需要调整。

  • 最后,需要传递 --dmd 选项,以便运行 DMD。--mode=scan 是必需的,这样当我们获得 DMD 日志时,每个内存块的全部内容都会被保存以供以后分析。

有了该命令行,您就可以启动 Firefox 了。请注意,如果禁用了优化,这可能需要几分钟。

启动后,完成您需要重现泄漏的步骤。如果您的泄漏是一个幽灵窗口,获取 about:memory 报告并记下泄漏进程的 PID 可能会很有帮助。您可能需要在此之后等待 10 秒左右,以确保尽可能多地清理。

接下来,退出浏览器。这将导致写入大量日志,因此可能需要一段时间。

分析日志

获取泄漏对象的 PID 和地址

第一步是确定泄漏进程的 **PID**。第二步是确定 **泄漏对象的地址**,通常是窗口。方便的是,您通常可以使用循环收集器日志一次完成这两项操作。如果您正在调查 www.example.com 的泄漏,则从 $logdir 中您可以执行 "grep nsGlobalWindow cc-edges* | grep example.com"。这会查看所有 CC 日志中的所有窗口(这些窗口可能泄漏,在关闭的后期),然后过滤掉 URL 包含 example.com 的窗口。

该 grep 的结果将包含如下所示的输出

cc-edges.15873.log:0x7f0897082c00 [rc=1285] nsGlobalWindowInner # 2147483662 inner https://www.example.com/

cc-edges.15873.log:第一部分是找到它的文件名。15873 是泄漏的进程的 PID。您需要记下文件名和 PID。让我们将文件称为 $cclog,将 pid 称为 $pid

0x7f0897082c00:这是泄漏窗口的地址。您还需要记下它。让我们将其称为 $winaddr

如果有多个文件,您将得到一个类似于 cc-edges.$pid.log 的文件,以及一个或多个类似于 cc-edges.$pid-$n.log 的文件,其中 $n 的值为各种值。您需要 $n 最大的那个,因为它是最近记录的,因此它将包含最少的非垃圾。

在循环收集器日志中识别根

下一步是使用 heapgraph 存储库中的 find_roots.py 脚本找出为什么循环收集器无法收集该窗口。调用此脚本的命令如下所示

python $heapgraph/find_roots.py $cclog $winaddr

这可能需要几秒钟。它最终会产生一些输出。您需要保存此输出的副本以备后用。

在关于加载进度的消息之后,输出将如下所示

0x7f0882fe3230 [FragmentOrElement (xhtml) script https://www.example.com]
--[[via hash] mListenerManager]--> 0x7f0899b4e550 [EventListenerManager]
--[mListeners event=onload listenerType=3 [i]]--> 0x7f0882ff8f80 [CallbackObject]
--[mIncumbentGlobal]--> 0x7f0897082c00 [nsGlobalWindowInner # 2147483662 inner https://www.example.com]

Root 0x7f0882fe3230 is a ref counted object with 1 unknown edge(s). known edges: 0x7f08975a24c0 [FragmentOrElement (xhtml) head https://www.example.com] –[mAttrsAndChildren[i]]–> 0x7f0882fe3230 0x7f08967e7b20 [JS Object (HTMLScriptElement)] –[UnwrapDOMObject(obj)]–> 0x7f0882fe3230

前两行表示脚本元素 0x7f0882fe3230 包含对 EventListenerManager 0x7f0899b4e550 的强引用。“[via hash] mListenerManager” 是该强引用的描述。总之,这些行显示了从循环收集器认为需要保持活动状态的对象 0x7f0899b4e550 到您询问的对象 0x7f0897082c00 的强引用链。大多数情况下,实际的链并不重要,因为循环收集器只能告诉我们哪些操作是正确的。让我们将泄漏对象的地址(在本例中为 0x7f0882fe3230)称为 $leakaddr

除了 $leakaddr 之外,另一个有趣的部分是底部的块。它告诉我们有一个未知边,以及两个已知边。这意味着泄漏对象的 refcount 为 3,但循环收集器仅被告知这两个引用。在这种情况下,一个 head 元素和一个 JS 对象(脚本元素的 JS 反射器)。我们需要找出未知引用来自哪里,因为那才是我们的泄漏所在。

找出是什么使泄漏对象保持活动状态。

现在我们需要使用 DMD 堆扫描日志。这些包含每个活动内存块的内容。

使用 DMD 堆扫描日志的第一步是对 DMD 日志进行一些预处理。需要符号化堆栈,并且我们需要钳位堆中包含的值。钳位与保守 GC 执行的分析类型相同:如果堆块中的一个字对齐值指向另一个堆块内的某个位置,则用该块的地址替换该值。

这两种预处理都是由 dmd.py 脚本完成的,可以像这样调用

$objdir/dist/bin/dmd.py --clamp-contents dmd-$pid.log.gz

由于符号化,这可能需要几分钟,但您只需要在日志文件上运行一次。

您还可以本地符号化从 TreeHerder 生成的 DMD 日志中的堆栈,但它将需要执行几个额外的步骤,您需要在运行 dmd.py 之前执行。

完成后,我们终于可以使用 block_analyzer 脚本找出哪些对象(可能)指向其他对象

python $srcdir/memory/replace/dmd/block_analyzer.py dmd-$pid.log.gz $leakaddr

此工具会遍历日志中的每个内存块,并提供关于可能包含指向该对象的指针的任何内存块的一些基本信息。您可以传递一些额外的选项来影响结果的显示方式。“-sfl 10000 -a”很有用。-sfl 10000 告诉它不要截断堆栈帧,-a 告诉它不要显示与分配器相关的通用帧。

警告:我认为 block_analyzer.py 不会尝试限制您传递给它的地址,因此如果它是从块开头开始的偏移量,它将找不到它。

block_analyzer.py` will return a series of entries that look like this
with the [...] indicating where I have removed things):
0x7f089306b000 size = 4096 bytes at byte offset 2168
nsAttrAndChildArray::GrowBy[...]
nsAttrAndChildArray::InsertChildAt[...]
[...]

0x7f089306b000 是包含 $leakaddr 的块的地址。144 字节是该块的大小。这对于确认您对块实际所属类的猜测很有用。字节偏移告诉您指针在块中的位置。这主要对较大的对象有用,您可以将其与调试信息结合起来,以准确确定这是什么字段。其余的条目是块分配的堆栈跟踪,这是最有用的信息。

您现在需要做的是遍历每个条目,并将它们归入三类:循环收集器已知的强引用、弱引用或其他!目标是最终缩小“其他”类别,直到其中只包含与泄漏对象的未知引用一样多的内容,然后您就找到了泄漏源。

要将条目归入其中一个类别,您必须查看堆栈跟踪中给出的代码位置,并查看您是否可以根据该位置判断对象是什么,然后将其与 find_roots.py 提供的信息进行比较。

例如,CC 日志中的一个强引用来自头部元素通过 mAttrsAndChildren 到其子元素的引用,这听起来很像这样,因此我们可以将其标记为已知的强引用。

这是一个迭代过程,您首先遍历并标记易于分类的内容,然后重复此过程,直到您得到一个需要分析的小列表。

block_analyzer.py 结果示例分析

在一个我调查 bug 1451985 泄漏的调试会话中,我最终减少了条目的列表,直到这是看起来最可疑的条目

0x7f0892f29630 size = 392 bytes at byte offset 56
mozilla::dom::ScriptLoader::ProcessExternalScript[...]
[...]

我转到 ScriptLoader::ProcessExternalScript() 的那行代码,它包含对 ScriptLoader::CreateLoadRequest() 的调用。幸运的是,此方法主要只包含两个对 new 的调用,一个用于 ScriptLoadRequest,另一个用于 ModuleLoadRequest。(这就是非优化版本派上用场的地方,因为它会指出确切的行。不幸的是,在这种特定情况下,非优化版本非常慢,我无法获得任何日志。)然后我查看了 XPCOM_MEM_BLOAT_LOG 生成的泄漏对象列表,发现我们泄漏了一个 ScriptLoadRequest,因此我查看了它的类定义,在那里我注意到 ScriptLoadRequest 对一个元素具有强引用,但它没有告诉循环收集器,这看起来很可疑。

我首先尝试确认这是泄漏源的方法是将此对象的地址传递给我们在早期步骤中使用的循环收集器分析日志 find_roots.py。这给出了一个包含以下内容的结果

0x7f0882fe3230 [FragmentOrElement (xhtml) script [...]
--[mNodeInfo]--> 0x7f0897431f00 [NodeInfo (xhtml) script]
[...]
--[mLoadingAsyncRequests]--> 0x7f0892f29630 [ScriptLoadRequest]

这确认此块实际上是一个 ScriptLoadRequest。其次,请注意,加载请求正被导致窗口泄漏的同一个脚本元素保持活动状态!这强烈表明脚本元素和加载请求之间存在一个强引用的循环。然后,我将缺失的字段添加到 ScriptLoadRequest 的遍历和取消链接方法中,并确认我无法重现泄漏。

请记住,您可能需要多次运行 block_analyzer.py。例如,如果脚本元素被某个容器保持活动状态,而该容器又被某个可运行对象保持活动状态,那么我们首先需要弄清楚容器是否正在持有该元素。如果无法确定是什么使它保持活动状态,则必须再次运行 block_analyzer。这没什么大不了的,因为与引用计数日志记录不同,我们在现有的日志中拥有完整的内存状态,因此我们不需要再次运行浏览器。