Node.js 中的内存泄漏 - 如何分析分配 tree/roots?

Memory leaks in Node.js - How to analyze allocation tree/roots?

查找内存泄漏是一项非常困难的任务,尤其是当涉及到使用许多第三方库的现代 JS 代码时。例如,我目前正面临 rollup 中的内存泄漏,涉及 babel 和自定义 babel 插件。我正在探索几种常见的策略来追捕它们:

  1. 了解您的 运行time,它的内存解除分配方案,并遵循有关该方案的最佳实践。
    • This article claims that all modern JS runtime implementations use a Mark-and-sweep garbage collector. One of its major strengths is that it can properly deal with circular references. (The article also links this very outdated workshop paper。不要太在意它,因为它都是关于循环引用的,这应该不再是问题了。)
    • This article 深入探讨 V8 内存管理(注意:Node 和 Chrome 均基于 V8)。
  2. 如果您发现内存或 GC 使用量激增超出您的预期,请分析您的堆内存配置文件以找出内存分配的位置。
    • This SO answer explains how to do that in Chrome, but its links are outdated. This 是相关 Chrome 文档的直接 link(截至 2021 年)。
    • 对于 Node,我发现很多 过时的信息。目前,分析堆内存配置文件的最简单方法似乎是使用实验性的 --heap-prof 命令行参数(例如 node --heap-prof node_modules/rollup/dist/bin/rollup -c 来分析汇总构建)。然后通过 Memory -> Load.
    • 在 Chrome Dev Tools 中打开它
    • 经过分析,我们可以了解到where/how大部分内存已分配;但一个关键问题尚未得到解答:
  3. 如果你知道谁是罪魁祸首(记忆猪),你怎么能找出为什么/哪里他们仍然挥之不去?而且,更重要的是:内存占用对象的 GC 根(堆栈指针)是什么?

最后一个问题也是我在这里的问题:我们如何分析Node(或一般的V8)中​​的对象分配树?我如何找出我在步骤 (2) 中识别的对象在哪里踢来踢去?

通常,这个问题的答案 告诉我们在哪里更改代码以阻止泄漏。 (当然,如果你的问题是memory churn,而不是内存泄漏,那么这个问题可能就不那么重要了。)

在我的示例中,我知道内存被 Babel AST 节点和路径对象占用,但我不知道它们为什么会徘徊,也就是说我不知道​​它们存储在哪里。如果你只是 运行 Babel 本身,你可以验证它不是 Babel 泄漏内存。我目前正在尝试各种技巧来找出它们的存储位置,但仍然没有运气。

遗憾的是,到目前为止,我还没有找到任何工具来帮助解决问题 (3)。甚至相关的深入文章(如 this and its slidedeck hereMANUALLY 制定了堆分配步骤。感觉好像没有这样的工具,还是我错了?如果没有工具,也许某个地方有关于这个的讨论?

Chrome DevTools 有一个“堆快照”功能,除其他功能外,它还可以让您检查对象的“保留路径”(本质上就是您的“问题 3”)。有关详细信息,请参阅 https://developers.google.com/web/tools/chrome-devtools/memory-problems/heap-snapshots

当您使用 --inspect 启动 Node 时,您可以将 DevTools 连接到 Node。有关详细信息,请参阅 https://nodejs.org/en/docs/guides/debugging-getting-started/

请注意,虽然您不必在 JS 中显式释放内存,但内存泄漏仍然可能发生。同时,Node 内存分析实用程序(几乎是犯罪)的文档不足。让我们看看如何使用它们。

TLDR:跳到下面的 hands-on 部分,标题为“查找内存泄漏(含示例)”。

JS 中的内存泄漏

由于JS有一个GC,内存泄漏只有几个可能的原因:

  • 您挂在(“保留”)不再使用的大型 object 上,通常在文件或全局范围内的变量中。这要么是偶然的,要么是简单化(不确定的)缓存方案的一部分:

    let a;
    function f() {
      a = someLargeObject;
    }
    
  • 有时 objects 在保留的闭包中挥之不去。例如:

    let cb;
    function f() {
      const a = someLargeObject;  // `a` is retained as long as `cb`
      cb = function g() {
        eval('console.log(a)');
      };
    }
    

您可以通过从不存储到或手动清除这些变量来轻松修复此类内存泄漏。主要难点在于找到这些挥之不去的objects.

使用 Chrome 开发工具分析节点应用程序

首先,Node.js和Chrome都使用相同的JS引擎:v8。因此,Chrome Dev Tools 团队添加 Node 调试和分析支持是可行的。虽然还有其他可用的工具,但 Chrome Dev Tools (CDT) 可能更成熟(并且可能资金更充裕),这就是为什么我们(现在)将专注于如何使用 Chrome 用于节点内存分析和调试的开发工具。

使用CDT分析节点内存的主要方法有两种:

  1. 运行 您的应用使用 --heap-prof 生成堆分析日志文件。然后加载并分析CDT.
  2. 中的日志
  3. 运行 您的应用 --inspect/--inspect-brk flag in order to debug your Node application in CDT. Then just use CDT's Memory tab (documentation here) 符合您的喜好。

方法一:heap-prof

运行 您的应用使用 --heap-prof 生成堆分析日志文件。然后加载并分析CDT.

中的日志

步骤

  1. 运行 您的应用程序启用了 heap-prof。例如:node --heap-prof app.js
  2. 查看工作目录(通常是您所在的文件夹 运行 应用程序)。有一个新文件,默认情况下,名为 Heap*.heapprofile.
  3. 在 Chrome 中打开一个新选项卡 → 打开 CDT → 转到“内存”选项卡
  4. 在底部,按Load→selectHeap*.heapprofile
  5. 完成。您现在可以看到在录制结束时仍然存在的内存分配的位置。

方法 1 的注意事项

此步骤允许您首先验证内存泄漏,并查明哪种分配或 objects 可能导致它。

让我们看看CDT的内存分析工具。它具有三种模式:

可悲的是,--heap-prof记录的日志只包含模式1的数据。但是,这种模式不足以回答OP的第三个问题:如何找到why/where分配的[=286] =]s 仍然挥之不去(即:不再使用后“保留”)?

如选项卡中所述:回答该问题需要第二种模式。

我不知道是否有隐藏的方法可以更改Node的配置文件模式,但我没有找到。我尝试了一些东西,包括从 this list of undocumented Node.js CLI flags.

添加

这就是为什么 @jmrk proposed method (2) in his answer:

方法二:inspect/inspect-brk

运行 您的应用 --inspect/--inspect-brk flag in order to debug your Node application in CDT. Then just use CDT's Memory tab (documentation here) 符合您的喜好。

步骤

  1. 运行 调试模式下的应用程序,并在开头停止执行:node --inspect-brk app.js
  2. 在 Chrome 中打开 chrome://inspect
  3. 几秒钟后,您的应用程序应该出现在列表中。 Select它。
  4. CDT 已启动,您会看到执行在应用程序的入口点停止。
  5. 转到内存选项卡,select 第二种模式,然后按“记录”按钮
  6. 继续执行直到记录内存泄漏。为此,要么在某处设置断点,要么,如果泄漏一直持续到最后,则让应用程序自然退出。
  7. 返回“内存”选项卡并再次按“录制”按钮停止录制。
  8. 您现在可以分析日志(见下文)。

方法 2 的注意事项

  1. 因为您现在 运行 您的整个应用程序都处于调试模式,所以一切都 很多 慢。
  2. 堆模式 2 通常需要更多内存。如果内存超过 Node 默认内存限制(大约 2GB),它就会崩溃。监视您的内存使用情况,并可能使用 --max-old-space-size=4096(或更大的数字)之类的东西来加倍默认值。或者,更好的是,如果可能的话,简化您的测试用例以使用更少的内存并加快分析速度。
  3. “记录分配堆栈”选项显示分配任何 object 时的调用堆栈。这类似于 Profile mod 的功能1. 不需要查找内存泄漏。到目前为止我还不需要它,但是如果您需要将挥之不去的 object 映射到它们的分配,这应该会有所帮助。

查找内存泄漏(带示例)

完成方法 2 的步骤后,您现在可以查看查找泄漏所需的所有信息。

让我们看一些基本的例子:

示例 1

代码

下面的代码示例了一个简单的内存泄漏:file-scoped a 永久存储数据。

Complete Gist is here.

let a;
function test1() {
  const b = [];
  addPressure(N, b);
  a = b;
  gc(); // --expose-gc
}

test1();
debugger;

备注:

  • 找到“缠绵”object是我们的目标;这是“non-collectable”object; object虽然不再使用,但仍被保留。这就是为什么我通常会在分析时调用 gc 。这样我们就可以确保摆脱所有可收集的引用,并明确关注“挥之不去的”objects。
    • gc() 调用需要 expose-gc 标志;例如:node --inspect-brk --expose-gc app.js

内存查看

一旦遇到断点,我就停止记录,我得到这个:

  • Constructor 视图列出所有延迟 object,按 constructor/type 分组。
    • 确保您按 Shallow SizeRetained Size 排序(均有解释 here
  • 我们发现 string 占用了大部分内存。让我们打开它。
    • 在每个 Constructor 下方,您可以找到所有单独 object 的列表。第一个(最大的)object(s) is/are 通常是罪魁祸首。 Select第一个。
  • Retainers 视图现在会向您显示此 object 的保留位置。
    • 在这里你想找到长期保留它的功能(使其“流连忘返”)。

关于 Retainers 视图的文档还不够完整。这就是我尝试导航它的方式,直到它吐出我正在寻找的代码行:

  • Select 一个 object。
    • (同样,通常最容易完成此列表,按大小排序。)
  • 在 object 的树视图条目中:打开嵌套的树视图条目。
  • 查找任何引用一行代码的内容(显示在第一列的 right-hand-side 上)。
  • 标有“上下文”的条目可能比其他条目更有用。

我的发现显示在这张截图中:

我们看到三个函数在这个 object 的挥之不去中发挥作用:

  • 调用 gc 的函数 - 我不确定这是为什么。可能与 GC 内部结构有关。可能是因为 gc 会缓存对一些(如果不是全部)挥之不去的 objects.
  • 的引用
  • addPressure 函数分配了 object。这也是保留它的引用的来源。
  • test1 函数是我们将 object 分配给 file-scoped a 的地方。
    • 这才是真正的泄密!我们可以通过不将它分配给 a 来修复它,或者确保在它不再被使用后清除 a

结论

我希望,这可以帮助您开始寻找和消除内存泄漏的激动人心的旅程。欢迎在下方询问更多信息。