一直以来,跟踪 Node.js 的内存泄漏是一个反复出现的话题,人们始终希望对其复杂性和原因了解更多。
并非所有的内存泄漏都显而易见。但是,一旦我们确定了其模式,就必须在内存使用率,内存中保存的对象和响应时间之间寻找关联。在检查对象时,应该根据自己所用的框架或技术(例如服务器端渲染),研究收集了多少对象,以及它们是否正常。希望在完成本文结束之后,你将能够理解并寻找一种策略来调试 Node.js 程序的内存消耗。
Node.js 中的垃圾回收机制
JavaScript 是一种垃圾回收语言,而 Google 的 V8 最初是为 Google Chrome 创建的JavaScript引擎,在许多情况下都可以用作独立的运行时。Node.js 中垃圾收集器的两个重要操作是:
- 确定有用的或无用的对象,并且
- 回收或重用无用对象所占用的内存。
需要记住的要点:在垃圾回收器运行时,它将完全暂停你的程序,直到完成工作为止。因此,你需要通过维护对象的引用来最大程度地减少其工作。
V8 JavaScript 引擎会自动分配和取消分配 Node.js 进程使用的所有内存。让我们看看实际情况是怎样的。
如果你将内存视为一个树结构,那么可以想象 V8 从“根节点”开始保存程序中所有的变量。这可能是你的 window 对象,也可能是 Node.js 模块中的全局对象,通常称为控制者。需要牢记的一点是,你无法对怎样取消分配“根”节点进行控制。
接下来,你将找到一个 Object 节点,通常被称为叶子(没有子引用的节点)。最后 JavaScript 中有 4 种数据类型:布尔值,字符串,数字和对象。
V8 将遍历该树并尝试识别无法从“根”节点访问的数据组。如果无法从“根”节点访问该数据,则 V8 假定不再使用该数据,并释放内存。请记住:要确定某个对象是否处于活动状态,需要检查是否可通过被定义为活动对象的某个指针链到达;其他所有的情况,例如无法从根节点访问,或无法被根节点或另一个活动对象引用的对象,都会被视为垃圾。
简而言之,垃圾收集器有两个主要任务:
- 跟踪
- 计算对象之间的引用。
当你需要跟踪来自另一个进程的远程引用时,它可能会变得很棘手,但是在 Node.js 程序中,我们通常用单进程,这样使我们更加轻松。
V8 的内存方案
V8 使用类似于 Java 虚拟机的方案,并将内存划分为多个段。实现这种包装方案的东西被称为“驻留集”,它是指在 RAM 中驻留的进程所占用的内存部分。
在驻留集中,你会发现:
- 代码段:代码实际执行的位置。
- 栈: 包含局部变量和所有值类型,其指针引用堆上的对象或定义程序的控制流。
- 堆: 专门用于存储引用类型(如对象、字符串和闭包)的内存段。
还有重要的两点要记住:
- 对象的浅大小:保存对象本身所需的内存大小
- 对象的保留大小:当删除对象及其依赖对象时,被释放的内存大小
Node.js 有一个对象,以字节为单位描述 Node.js 进程的内存使用情况。在对象内部,你会发现:
- rss: 是指驻留集大小。
- heapTotal 和 heapUsed: 是指 V8 的内存使用情况。
- external: 是指与 V8 所管理的 JavaScript 对象绑定的 C++ 对象的内存使用情况。
查找泄漏
Chrome DevTools 是一个很棒的工具,可用于通过远程调试来诊断 Node.js 程序中的内存泄漏。也有其他为你提供类似功能的工具。但是,你需要记住,概要分析是一项繁重的 CPU 任务,可能会对你的程序产生负面影响,一定要注意这一点!
我们将要介绍的 Node.js 程序是一个简单的 HTTP API Server,它具有多个端点,向使用该服务的人返回不同的信息。你可以克隆这个程序的repository。
1const http = require('http')23const leak = []45function requestListener(req, res) {67 if (req.url === '/now') {8 let resp = JSON.stringify({ now: new Date() })9 leak.push(JSON.parse(resp))
10 res.writeHead(200, { 'Content-Type': 'application/json' })
11 res.write(resp)
12 res.end()
13 } else if (req.url === '/getSushi') {
14 function importantMath() {
15 let endTime = Date.now() + (5 * 1000);
16 while (Date.now() < endTime) {
17 Math.random();
18 }
19 }
20
21 function theSushiTable() {
22 return new Promise(resolve => {
23 resolve(' ');
24 });
25 }
26
27 async function getSushi() {
28 let sushi = await theSushiTable();
29 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
30 res.write(`Enjoy! ${sushi}`);
31 res.end()
32 }
33
34 getSushi()
35 importantMath()
36 } else {
37 res.end('Invalid request')
38 }
39}
40
41const server = http.createServer(requestListener)
42server.listen(process.env.PORT || 3000)
启动Node.js应用程序:
我们一直在使用 3S(3 Snapshot)方法进行诊断并确定可能的内存问题。有趣的是,我们发现这是 Gmail 团队的 Loreena Lee 长期使用的一种解决内存问题的方法。此方法的步骤:
- 打开 Chrome DevTools 并访问
chrome://inspect
。 - 在底部的“Remote Target”中,单击
inspect
按钮。
注意: 要确保已将 Inspector 附加到要分析的 Node.js 程序。你还可以用 ndb
连接到 Chrome DevTools。
当应用运行时,你将在控制台的输出中看到一条 Debugger Connected
消息。
- 转到 Chrome DevTools > Memory
- 获取堆快照
在这种情况下,我们得到了第一个快照,而服务没有进行任何负载或处理。这是针对某些用例的提示:如果我们能够确定在接受请求或进行某些处理之前不需要对程序进行任何预热,那就很好了。有时,在获取第一个堆快照之前先进行热身操作是有意义的,因为在某些情况下,你可能会在第一次调用时对全局变量进行了延迟初始化。
- 在你的程序中执行你认为导致内存泄漏的操作。
在这种情况下,我们将运行 npm run load-mem
。这将启动 ab
来模拟 Node.js 应用程序中的流量或负载。
得到堆快照
- 再次在你的程序中执行你认为会导致内存泄漏的操作。
- 获取最终的堆快照
- 选择最新得到的快照。
- 在窗口顶部,找到显示 “All objects” 的下拉列表,并将其切换为“Objects allocated between snapshots 1 and 2”。(如果需要,你也可以对 2 和 3 执行相同的操作)。这将大大减少你看到的对象数量。
比较视图也可以帮你识别那些对象:
在该视图中,你将看到泄漏对象的列表:顶级条目(每个构造函数一行)、对象到GC根的距离、对象实例数、浅大小和保留大小。你可以通过选择一行来查看其内容。一个好的经验法则是,首先忽略括号中的项目,因为它们是内置结构。@
字符是对象的唯一 ID,可让你比较每个对象的堆快照。
典型的内存泄漏可能是通过意外地将对对象的引用存储在无法进行垃圾回收的全局对象中,从而保留了预期仅在一个请求周期内持续存在的对象的引用。
这个例子故意留下了一个内存泄漏的问题,在请求一个从 API 查询返回的对象时生成带有日期时间戳的随机对象,并将其存储在全局数组中来泄漏该对象。通过查看几个保留的对象,你会看到一些泄漏数据的示例,可用于跟踪应用程序中的泄漏。
NSolid 非常适合这种类型的用例,因为它可以使你很好地了解在执行的每个任务或负载测试中内存是怎样增加的。如果你感到好奇,还可以实时查看每个性能分析动作如何影响 CPU。
demo
在实际项目中,你不可能总是盯着用于监视程序的工具。NSolid 的一大优点是可以为应用程序的不同指标设置阈值和限制。例如,你可以将 NSolid 设置为在使用的内存量超过 X 时,或者在 X 时间内尚未从高消耗高峰恢复内存的情况下,进行堆快照。听起来不错吧?
标记和清理
V8 的垃圾收集器主要基于 Mark-Sweep 收集算法,该算法包括跟踪垃圾收集,该操作通过标记可达的对象,然后清理内存并回收未标记的对象(必须无法访问),将其纳入释放列表。这也称为世代垃圾收集器,对象可以在新声代、从新生代到老生代、以及老生代中移动。
移动对象的代价非常打,因为需要将对象的基础内存复制到新位置,并且指向这些对象的指针也需要更新。
用人话解释:
V8 递归查找所有对象到“根”节点的引用路径。例如:在 JavaScript 中,“window” 对象是可以充当 Root 的全局变量的示例。window 对象始终存在,因此垃圾收集器可以认为它及其所有子对象始终存在(即不是垃圾)。如果有任何引用,则没有指向“根”节点的路径。特别是当它以递归方式查找未引用的对象时,将被标记为垃圾,稍后将会被清除以释放该内存并将其返回给操作系统。
但是,现代的垃圾收集器以不同的方式对这种算法进行了改进,但本质是相同的:可访问的内存被标记为一类,其余的被视为垃圾。
请记住,从根可以访问到的所有内容均不视为垃圾。不需要的引用是保留在代码中某个位置的变量,这些变量将不再使用,并且指向可以释放的内存,因此,要了解 JavaScript 中最常见的泄漏,我们需要了解通常忘记引用的方式。
Orinoco 垃圾收集器
Orinoco 是最新 GC 项目的代号,它利用最新的增量和并发技术进行垃圾回收,并有释放主线程的功能。描述 Orinoco 性能的重要指标之一是垃圾回收器执行时主线程暂停的频率和时间。对于经典的“世界末日”收集者而言,这些时间间隔会因为延迟、质量差的渲染以及响应时间的增加而影响程序的用户体验。
V8 在新声代内存中的辅助流之间分配垃圾回收工作(清除)。每个流接收一组指针,然后将所有活动对象移动到“to-space”。
将对象移至“to-space”时,线程需要通过读、写、比较和交换的原子操作进行同步,以避免出现另一个线程找到相同的对象但遵循不同路径并尝试移动的情况。
引用自 V8 官网:
在现有 GC 中添加并行、增量和并发技术是一项多年的努力,但已取得了回报,将大量工作移交给了后台任务。它大大改善了暂停时间、延迟和页面加载,使动画、滚动和用户交互更加顺畅。并行的 Scavenger 根据工作量将主线程新声代垃圾收集的总时间减少了大约 20%–50%。Idle-time GC 可以在 Gmail 空闲时将其 JavaScript 堆内存减少 45%。并发标记和清除可以将笨重的 WebGL 游戏中的暂停时间减少多达 50%。
Mark-Evacuate 收集器包括三个阶段:标记、复制和更新指针。为了避免在新声代中清理页面以维护空闲列表,仍然使用 semi-space 来维护新生代,它始终保持紧凑状态,即在垃圾回收期间将活动对象复制到 “to-space” 中。并行进行的好处是可以获得“exact liveness”信息。通过仅移动和重新链接主要包含活动对象的页面,可以用此信息来避免复制,这也可以由完整的 Mark-Sweep-Compact 收集器执行。它通过和标记清除算法相同的方式标记堆中的活动对象来工作,这意味着堆通常会被碎片化。V8 当前随附有并行的 Scavenger,可在大量基准测试中减少主线程新生代垃圾回收约 20%–50% 的总时间。
与暂停主线程、响应时间和页面加载有关的所有方面都得到了显着改善,这使得页面上的动画、滚动和用户交互更加流畅。并行收集器可以将新内存的总处理时间减少 20–50%,具体取决于负载。但是工作还没有结束:减少停顿仍然是一项重要任务,我们将继续寻找使用更先进的技术来实现这一目标的可能性。
总结
大多数开发人员在开发 JavaScript 程序时无需考虑 GC,但是了解一些内部知识可以帮助你考虑内存使用情况和有用的编程模式。例如考虑到 V8 中基于世代的堆结构,从 GC 角度来说,维护低生存期的对象的成本实际上是相当低的,因为我们主要为存在的对象付出代价。这种模式不仅特定于 JavaScript,而且对于许多支持垃圾回收的语言也都有效。
要点:
- 请勿使用过时或不推荐的软件包(例如,node-memwatch,node-inspector 或 v8-profiler)来检查内存。你需要的一切都已经集成在了 Node.js 的二进制文件中(尤其是 node.js 检查器和调试器)。如果你需要更专业的工具,则可以使用 NSolid、Chrome DevTools 或其他知名软件。
- 考虑在何时何地触发堆快照和 CPU profile。由于要在生产环境中进行快照,你将会希望同时触发这两者(主要是在测试中),所以这会需要大量的 CPU 操作。另外,在关闭进程和进行冷重启之前,请确认有多少堆转储被写入了。
- 没有哪一种工具可以解决所有问题。要根据程序的具体情况进行测试、测量、判断和解决。选择适合你体系结构的最佳工具,并选择一种可以提供更多有用数据来帮你解决问题的工具。
原文:https://nodesource.com/blog/memory-leaks-demystifi