本节会重点分析内存和进程奔溃,并且会给出相应的监控方法。

  本系列所有的示例源码都已上传至Github,点击此处获取。

一、内存

  虽然在 Node.js 中并不需要手动的对内存进行分配和销毁,但是在开发中因为程序编写问题也会发生内存泄漏的情况。

  所以还是有必要了解一些 Node.js 开放的内存操作和常见的内存泄漏场景。

1)内存指标

  Node.js 项目在启动后(例如 node index.js),会创建一个服务进程。进程是具有独立功能的程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位。

  程序在运行时会被分配一些内存空间,这个空间称为常驻内存(Resident Set),V8 会将内存分为几个段(也叫存储空间):

  • 代码(Code):存储可执行的代码。
  • 栈(Stack):存储原始类型的值(例如整数、布尔值等),以及对象的引用地址(指针)。
  • 堆(Heap):存储引用类型的值,例如对象、字符串和闭包。

  在下图中描绘了各个段,以及之间的关系。

  

  Node.js 提供了 process.memoryUsage() 方法,用于读取一个描述 Node.js 进程的内存使用量对象,所有属性值都以字节为单位。

  • rss:resident set size (常驻内存大小)的缩写,表示进程使用了多少内存(RAM中的物理内存),包括所有 C++ 和 JavaScript 对象和代码。
  • heapTotal:堆的总大小,包括不能分配的内存,例如在垃圾回收之前对象之间的内存碎片。
  • heapUsed:堆的使用量,已分配的内存,即堆中所有对象的总大小。
  • external:使用到的系统链接库所占用的内存,包含 C++ 模块的内存使用量。
  • arrayBuffers:为所有 Buffer 分配的内存,它被包含在 external 中。当 Node.js 被用作嵌入式库时,此值可能为 0,在这种情况下可能不会追溯 ArrayBuffer 的分配。

  下面的例子演示了本机的进程内存使用情况,默认都是字节,为了便于阅读,已将输出结果换算成 MB。

// 换算成 MB
function format (bytes) {
  return (bytes / 1024 / 1024).toFixed(2) + 'MB';
};
// 进程的内存使用
const mem = process.memoryUsage();    // 单位 字节
// {
//   rss: '20.05MB',
//   heapTotal: '3.86MB',
//   heapUsed: '3.02MB',
//   external: '0.24MB',
//   arrayBuffers: '0.01MB'
// }
console.log({
  rss: format(mem.rss),
  heapTotal: format(mem.heapTotal),
  heapUsed: format(mem.heapUsed),
  external: format(mem.external),
  arrayBuffers: format(mem.arrayBuffers)
});

  在 os 模块中,有两个方法:freemem() 和 totalmem(),分别表示系统的空闲内存和总内存。

  以本机为例,电脑的内存是 16G,因此总内存也是这个数,而系统的空闲内存会动态变化。

const os = require('os');
// 系统的空闲内存
const freemem = os.freemem();
format(freemem);   // 178.58MB
// 系统所有的内存
const totalmem = os.totalmem();
format(totalmem);  // 16384.00MB = 16G

2)内存泄漏

  内存泄漏(memory leak)是计算机科学中的一种资源泄漏,主因是程序的内存管理失当,因而失去对一段已分配内存的控制。

  程序继续占用已不再使用的内存空间,或是存储器所存储对象无法透过执行代码而访问,令内存资源空耗。下面会罗列几种内存泄漏的场景:

  第一种是全局变量,它不会被自动回收,而是会常驻在内存中,因为它总能被垃圾回收器访问到。

  第二种是闭包(closure),当一个函数能够访问和操作另一个函数作用域中的变量时,就会构成一个闭包,即使另一个函数已经执行结束,但其变量仍然会被存储在内存中。

  如果引用闭包的函数是一个全局变量或某个可以从根元素追溯到的对象,那么就不会被回收,以后不再使用的话,就会造成内存泄漏。

  第三种是事件监听,如果对某个目标重复注册同一个事件,并且没有移除,那么就会造成内存泄漏,之前记录过一次这类内存泄漏的排查

  第四种是缓存,当缓存中的对象属性越来越多时,长期存活的概率就越大,垃圾回收器也不会清理,部分不需要的对象就会造成内存泄漏。

3)heapdump

  想要定位内存泄漏,可以使用快照工具(例如 heapdump、v8-profiler 等)导出内存快照,使用 DevTools 查看内存快照。

  在下面的示例中,会在全局缓存之前和之后导出一份内存快照。

const heapdump = require('heapdump');
// 内存泄漏前的快照
heapdump.writeSnapshot('prev.heapsnapshot');
// 全局缓存
const cached = [];
for(let i = 0; i < 10; i++)
  cached.push(new Array(1000000));
// 内存泄漏后的快照
heapdump.writeSnapshot('next.heapsnapshot');

  得到文件后,打开 Chrome DevTools,选择 Memory =》Profiles =》Load 加载内存快照。

  默认是 Summary 视图,显示按构造函数名称分组的对象,如下图所示。

  

  视图中的字段包括:

  • Contructor:使用构造函数创建的对象,其中 (closure) 表示闭包。后面增加 * number 表示构造函数创建的实例个数。
  • Distance:到 GC 根元素的距离,距离越大,引用越深。
  • Shallow Size:对象自身的大小,即在 V8 堆上分配的大小,不包括它引用的对象。
  • Retained Size:对象自身的大小和它引用的对象的大小,即可以释放的内存大小。

  切换到 Comparison 视图,选择比较的内存快照(next.heapsnapshot),检查两者的数据差异和内存变化,如下图所示。

  

  如果 Delta 一直增长,那么需要特别注意,有可能发生了内存泄漏,视图中的所有字段说明如下所列:

  • # New:新建的对象个数。
  • # Deleted:删除的对象个数。
  • # Delta:发生变化的对象个数,净增对象个数。
  • Alloc.Size:已经分配的使用中的内存。
  • Freed Size:为新对象释放的内存。
  • Size Delta:可用内存总量的变化,上图中的数字是负数,说明可用内存变少了。
  • Containment 视图提供了一种从根元素作为入口的对象结构鸟瞰图,如下图所示。

  

  打开 GC roots =》 Isolate =》 Array 可以看到在代码中插入给 cached 数组的 10 个元素。

  

  要想能快速定位线上的内存泄漏,需要很多次的实践,知道字段含义仅仅是第一步。

  还需要在这么多信息中,定位到问题代码所在的位置,这才是监控地最终目的。

二、Core Dump

  Core Dump(核心转储)是操作系统在进程收到某些信号而终止运行时,将此时进程地址空间的内容以及有关进程状态的其他信息写入一个磁盘文件中。

  在这个文件中包含内存分配信息 、堆栈指针等关键信息,对于诊断和分析程序异常非常重要,因为可以还原真实的案发现场。

1)lldb

  本机是 Mac OS,默认自带了 lldb 命令,先用此命令来加载和分析 Core Dump 文件。

  首先要在终端放开 Core Dump 文件的大小限制,这样才能成功生成,命令如下。

ulimit -c unlimited

  但是一开始怎么样都生成不了,查了 Mac 官方文档stackoverflow 等各种网络资料都无济于事。

  后面自己才不经意的发现,这个命名只有在当前终端才有效,换个终端或 Tab 页都将无效,白白浪费了 3 个小时。

  然后创建 error.js 文件,里面就写一段会报错的代码,例如读取 undefined 的属性。

const test = { };
setTimeout(() => {
  console.log(test.obj.name);
}, 1000);

  接着在终端输入启动的命令,但是需要带上参数 --abort-on-uncaught-exception。

node --abort-on-uncaught-exception error.js

  代码运行完成后,Mac OS 就会在 /cores 目录中生成一个 core.[pid] 的文件,pid 就是当前进程的编号,通过 process.pid 也能读取到。

  在本地生成了一个 core.5889 文件,足足有 1.8G,怪不得不能随便生成,硬盘吃不消。最后输入 lldb 命令加载和分析文件。

lldb -c core.5889

  在加载成功成功后,会有一段提示。在最后一行需要手动输入 bt(backtrace)查看堆栈信息。

  

  上述是 C++ 的堆栈,可以看到 uv_run 开启事件循环,然后运行 uv__run_timers 阶段,接着就发生了错误,底层的错误内容看不大懂。

2)llnode

  这个 llnode 其实是 lldb 的一个插件,能还原 JavaScript 堆栈帧、对象、源代码等可读信息,类似于 Source Map 的功能。

  直接运行安装命令 npm install llnode 会报错,如下所示。

Reading lldb version...
xcode-select: error: tool 'xcodebuild' requires Xcode, but active developer directory '/Library/Developer/CommandLineTools' is a command line tools instance
Error: Command failed: xcodebuild -version
xcode-select: error: tool 'xcodebuild' requires Xcode, but active developer directory '/Library/Developer/CommandLineTools' is a command line tools instance

  查看官方文档,在 Mac OS 中,需要安装 LLDB 及其库或者直接安装 Xcode 并使用它附带的 LLDB,前者的命令如下。

brew install --with-lldb --with-toolchain llvm

  但是这条命令会报下面的错误,于是将 --with-lldb 参数去除。

Error: invalid option: --with-lldb

  再运行一次,持续了一个小时,才下载 32%,最后又是报错。

Error: invalid option: --with-toolchain

  无奈就想到去安装 Xcode,但是集成软件太大,要 10G多,于是选择 Command Line Tools (macOS 10.14) for Xcode 10.1,下载了 20 多分钟。

  安装完成后,还是无法下载 llnode 包,只得去下载 Xcode 10.1,又是 20 多分钟,Xcode_10.1.xip 是一个压缩包,需要解压。

  解压安装完成后,将当前目录的 Xcode 移动到应用程序目录,运行下面命令。

sudo xcode-select -s /Applications/Xcode.app/Contents/Developer

  重新下载 llnode 包,这次终于不报错了,开始出现下面的提示。

Looking for llvm-config...
⠹ [0/1] Installing llnode@*No llvm-config found

Reading lldb version...
⠼ [0/1] Installing llnode@*Deduced lldb version from Xcode version: Xcode 10.1 -> lldb 3.9
Installing llnode for lldb, lldb version 3.9

Looking for headers for lldb 3.9...
Could not find the headers, will download them later

Looking for shared libraries for lldb 3.9...
Could not find the shared libraries 
llnode will be linked to the LLDB shared framework from the Xcode installation

  因为没有全局安装 llnode,所以加载命令要加 npx,core.5889 加了绝对路径。

npx llnode -c /cores/core.5889

  下图是成功加载后的图,运行 v8 bt 命令后,并没有得到预期的堆栈信息。

  

  过程非常曲折,最后还是很遗憾没有成功解析,不知道是 lldb 的问题还是生成的文件问题,亦或是 Node 版本的问题。

  如果不想这么麻烦的解析,还可以直接使用成熟的 Node.js 性能平台,也有 Coredump 文件分析,并且做了深度定制,能更清晰地看到错误源码。

 

参考资料:

Node.js 环境性能监控探究

Nodejs: MemoryUsage()返回的rss,heapTotal,heapUsed,external的含义和区别

What do the return values of node.js process.memoryUsage() stand for? 

如何分析 Node.js 中的内存泄漏  Node.js 应用故障排查手册

Record heap snapshots

前端内存泄露浅析  Node常用dump分析

Chrome Memory Tab: Learn to Find JavaScript Memory Leaks

Node 案发现场揭秘 —— Coredump 还原线上异常

Node.js调试之llnode篇  Node调试指南-uncaughtException

coredump lldb常用命令与调试技巧

node常用dump分析 Node调试指南-内存篇

Explore Node.js core dumps using the llnode plugin for lldb

v8 source list always fails w/ error: USAGE: v8 source list

 posted on 2022-07-18 06:21  咖啡机(K.F.J)  阅读(1347)  评论(0编辑  收藏  举报