Node.js精进(9)——性能监控(上)

  市面上成熟的 Node.js 性能监控系统,监控的指标有很多。

  以开源的 Easy-Monitor 为例,在系统监控一栏中,指标包括内存、CPU、GC、进程、磁盘等。

  这些系统能全方位的监控着应用的一举一动,并且可以提供安全提醒、在线分析、导出真实状态等服务。

  本专题分为上下两个篇章,会简单分析下在 Node.js 环境中的几个资源瓶颈,包括CPU 、内存和进程奔溃,并且会给出相应的监控方法。

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

一、CPU

  在 Linux 系统中,可以通过 top 命令看到当前的 CPU 资源利用率、内存使用等信息,并且可按特定指标排序,类似于 Windows 的任务管理器。

  在 Node.js 中,提供了两个方法可以读取和计算出 CPU 负载和 CPU 使用率两个指标。

  这两个指标在一定程度上都可以反映一台计算机的繁忙程度。

1)CPU 负载

  CPU 负载是指在一段时间内等待或占用 CPU 的进程数,进程是操作系统中资源分配的最小单位。

  平均负载(Load Average)就是那些进程数除以时间得到的平均数。

  假设一台计算机只有一个 CPU 并且是一核,将 CPU 比作一座只有一条单向车道的桥,车比作进程。

  • 当平均负载为 0 时,桥上没有车。
  • 当平均负载为 0.5 时,桥上一半路段有车。
  • 当平均负载为 1 时,桥上所有路段都有车,虽然大桥已满,但不会堵车。
  • 当平均负载为 2 时,大桥已满,并且还多了一样多的车在桥外排队等待。

  如果 CPU 每分钟可以处理 100 个进程,那么当平均负载是 2 时,还有 100 个进程在排队等待中。

  现在的芯片厂商往往会让 1 个 CPU 包含多个核,并且还能将 1 个核虚拟成 2 个逻辑 CPU,CPU 负载建议的计算方式是:

(CPU个数 * 核数 * 2 * 0.8)或者(CPU个数 * 核数 * 2 * 0.7)

  不建议 CPU 长期满负荷工作。对于平均负载的量化,会采用三个时间标准:1 分钟,5 分钟和 15 分钟。

  1 分钟的时间比较短,有时候峰值突然升高,有可能是暂时现象。

  5 分钟和 15 分钟是较为合适的评判指标,当这两个时间段内的平均负载都大于 1,那就表明问题持续存在。

  这是一个危险的信号,CPU 上等待的进程在增多,若不及时清理,就会越堵越长,影响程序的正常运行。

  在 os 模块中,提供了 loadavg() 方法,可以得到一个包含 1、5 和 15 分钟的平均负载的数组。

const os = require("os");
os.loadavg();    // [ 1.9951171875, 1.951171875, 1.93359375 ]

  注意,平均负载是 Unix 特有的概念,在 Windows 上,返回值始终为 [0, 0, 0]。

2)CPU 使用率

  CPU 使用率是指程序在运行期间占用 CPU 的百分比,也就是说量化 CPU 的占用情况,计算方式如下:

CPU使用率 = (1 - CPU空闲时间 / CPU总时间) * 100

  CPU 使用率高,并不意味着 CPU 负载也高,例如当前任务很少,其中有一个需要大量的计算(CPU 密集型场景),那么使用率会很高,但负载很低。

  CPU 负载高,并不意味着 CPU 使用率也高,例如当前任务很多,在任务执行过程中因为等待 I/O 使得 CPU 非常空闲(I/O 密集型场景),那么使用率就会变低,但负载很高。

  在 os 模块中,提供了 cpus() 方法,可得到以每个逻辑 CPU 内核信息组成的对象数组,如下所示。

[
  {
    model: 'Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz',
    speed: 2300,
    times: { user: 27207990, nice: 0, sys: 17891890, idle: 179286370, irq: 0 }
  },
  {
    model: 'Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz',
    speed: 2300,
    times: { user: 294240, nice: 0, sys: 352550, idle: 223732290, irq: 0 }
  },
]

  其中 times 属性是一些时间信息,其中 nice 值仅适用于 POSIX 平台。在 Windows 中,所有处理器的 nice 值始终为 0。

  • user:CPU 在用户模式下花费的毫秒数。
  • nice:CPU 在良好模式下花费的毫秒数。
  • sys:CPU 在系统模式下花费的毫秒数。
  • idle:CPU 在空闲模式下花费的毫秒数。
  • irq:CPU 在中断请求模式下花费的毫秒数。

  下面用一个示例计算 CPU 使用率,遍历 CPU 信息数组后,将各个时间依次累加,然后返回总时间和空闲时间,最后套用公式计算。

function getCPUInfo() {
  const cpus = os.cpus();
  let user = 0, nice = 0, sys = 0, idle = 0, irq = 0, total = 0;
  // 遍历 CPU
  for (const cpu in cpus) {
    const times = cpus[cpu].times;
    user += times.user;
    nice += times.nice;
    sys += times.sys;
    idle += times.idle;
    irq += times.irq;
  }
  total += user + nice + sys + idle + irq;
  return {
    idle,
    total,
  };
}
const cpu = getCPUInfo();
// CPU 使用率
const usage = (1 - cpu.idle / cpu.total) * 100;

3)v8-profiler

  Node.js 是基于 V8 引擎运行的,而 V8 引擎内部实现了一个 CPU Profiler,并且开放了相关 API,v8-profiler 就是一个基于这些 API 收集一些运行时数据(例如 CPU 和内存)的库。

  不过在安装时,会报错,因此需要换一个包:v8-profiler-next,基于 v8-profiler,兼容 Node.js V4 以上的所有版本。

../src/cpu_profiler.cc:6:9: error: no member named 'Handle' in namespace 'v8'; did you mean 'v8::CodeEventHandler::Handle'?

  在下面的示例中,是一段需要消耗 CPU 计算的加密代码。

const crypto = require('crypto');
const password = 'test'
const salt = crypto.randomBytes(128).toString('base64')
crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex')

  在下面的示例中,会在 1 分钟后导出一份 CPU 分析文件,运行后会在当前目录生成 cpuprofile 后缀的文件。

const fs = require('fs');
const v8Profiler = require('v8-profiler-next');
const title = 'test';
// 兼容 vscode 中的 cpuprofile 解析
v8Profiler.setGenerateType(1);
v8Profiler.startProfiling(title, true);
// 1分钟后运行
setTimeout(() => {
  const profile = v8Profiler.stopProfiling(title);
  // 导出CPU分析文件
  profile.export(function (error, result) {
    fs.writeFileSync(`${title}.cpuprofile`, result);
    profile.delete();
  });
}, 60 * 1000);

  点击 Chrome DevTools 工具栏右侧的更多按钮,选择 More tools -> JavaScript Profiler 进入到 CPU 的分析页面。

  

  将分析文件 Load 进来,首先看到的是 Heavy 视图的分析结果,在图中选中的下拉框中还可以选择 Chart 和 tree。

  前者能显示火焰图,按时间顺序排列;后者能显示调用结构的总体状况,从调用堆栈的顶端开始,即从最初调用的位置开始。

  

  在 Heavy 视图中,会按照对应用的性能影响程度从高到低排列,这其中有 3 个指标:

  • Self Time:完成当前函数调用所用的时间,仅包括函数本身的语句,不包括它调用的任何子函数。
  • Total Time:完成此函数的当前调用以及它调用的任何子函数所花费的总时间。
  • Function:函数名及其全路径,可展开查看子函数。

  切换到 Tree 视图,逐层打开,就可以看到 pbkdf2Sync() 函数占据了 CPU 的大部分时间。

  

  上图中的 (program) 只计算了 native code 的时间,不包含执行脚本代码的时间(即没有在 JavaScript 的堆栈上),idle 也是 native 在执行 (program) 的一种。

二、垃圾回收器

  Node.js 是一个基于 V8 引擎的 JavaScript 运行时环境,而 Node.js 中的垃圾回收器(GC)其实就是 V8 的垃圾回收器。

  这么多年来,V8 的垃圾回收器(Garbage Collector,简写GC)从一个全停顿(Stop-The-World),慢慢演变成了一个更加并行,并发和增量的垃圾回收器。

  本节内容参考了 V8 团队分享的文章:Trash talk: the Orinoco garbage collector

1)代际假说

  在垃圾回收中有一个重要术语:代际假说(The Generational Hypothesis),这个假说不仅仅适用于 JavaScript,同样适用于大多数的动态语言,Java、Python 等。

  代际假说表明很多对象在内存中存在的时间很短,即从垃圾回收的角度来看,很多对象在分配内存空间后,很快就变得不可访问。

2)两种垃圾回收器

  在 V8 中,会将堆分为两块不同的区域:新生代(Young Generation)和老生代(Old Generation)。

  新生代中存放的是生存时间短的对象,大小在 1~ 8M之间;老生代中存放的生存时间久的对象。

  对于这两块区域,V8 会使用两个不同的垃圾回收器:

  • 副垃圾回收器(Scavenger)主要负责新生代的垃圾回收。如果经过垃圾回收后,对象还存活的话,就会从新生代移动到老生代。
  • 主垃圾回收器(Full Mark-Compact)主要负责老生代的垃圾回收。

  无论哪种垃圾回收器,都会有一套共同的工作流程,定期去做些任务:

  1. 标记活动对象和非活动对象,前者是还在使用的对象,后者是可以进行垃圾回收的对象。
  2. 回收或者重用被非活动对象占据的内存,就是在标记完成后,统一清理那些被标记为可回收的对象。
  3. 整理内存碎片(不连续的内存空间),这一步是可选的,因为有的垃圾回收器不会产生内存碎片。

3)副垃圾回收器

  V8 为新生代采用 Scavenge 算法,会将内存空间划分成两个区域:对象区域(From-Space)和空闲区域(To-Space)。

  副垃圾回收器在清理新生代时,会先将所有的活动对象移动(evacuate)到连续的一块空闲内存中(这样能避免内存碎片)。

  然后将两块内存空间互换,即把 To-Space 变成 From-Space。

  

  接着为了新生代的内存空间不被耗尽,对于两次垃圾回收后还活动的对象,会把它们移动到老生代,而不是 To-Space。

  

  最后是更新引用已移动的原始对象的指针。上述几步都是交错进行,而不是在不同阶段执行。

4)主垃圾回收器

  主垃圾回收器负责老生代的清理,而在老生代中,除了新生代中晋升的对象之外,还有一些大的对象也会被分配到此处。

  主垃圾回收器采用了 Mark-Sweep(标记清除)和 Mark-Compact(标记整理)两种算法,其中涉及三个阶段:标记(marking),清除(sweeping)和整理(compacting)。

  (1)在标记阶段,会从一组根元素开始,递归遍历这组根元素。其中根元素包括执行堆栈和全局对象,浏览器环境下的全局对象是 window,Node.js 环境下是 global。

  在这个遍历过程中,会追溯每一个指向 JavaScript 对象的指针,将其标记为可访问,同时追溯对象中每一个属性的指针。

  这个过程会一直持续至找到并标记运行时可到达的所有对象,而那些追溯不到的就是垃圾数据。

  (2)在清除阶段,会将非活动对象占用的内存空间添加到一个叫空闲列表的数据结构中。

  空闲列表中的内存块由大小来区分,这是为了方便以后需要分配内存时,可以快速的找到大小合适的内存空间并分配给新的对象。

  下图描绘了在将垃圾数据回收前后,内存占用的情况。

  

  可以看出,在执行清除算法后,会产生大量不连续的内存碎片。

  (3)在整理阶段,会让所有活动的对象都向一端移动,然后直接清理掉端边界以外的内存,如下图所示。

  

5)垃圾回收机制

  在本节开头提到了并行(parallel)、增量(incremental)和并发(concurrent)三种垃圾回收机制。

  (1)并行是指主线程和协助线程同时执行同样的工作,这仍然是一种全停顿。

  但垃圾回收所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)。

  (2)增量是指主线程间歇性的去做少量的垃圾回收,而不是花一整段时间去执行。

  虽然没有减少主线程暂停的时间,但 JavaScript 的执行都能得到及时的响应。

  (3)并发是指主线程一直执行 JavaScript,而辅助线程在后台执行垃圾回收,这种实现起来最难,需要处理很多复杂的场景。

  例如 JavaScript 堆上的任何东西都可以随时更改,使之前所做的工作无效。 况且现在有读/写竞争,辅助线程和主线程有可能同时在更改同一个对象。

  V8 在新生代垃圾回收中会使用并行清理,每个协助线程会将所有的活动对象都移动到 To-Space。

  主垃圾回收器主要使用并发标记,当堆的动态分配接近最高阈值时,会启动并发标记任务。

  V8 会利用主线程上的空闲时间主动的去执行垃圾回收,在 Chrome 中,大约有 16.6 毫秒的时间去渲染动画的每一帧。

  如果动画提前完成,那么就能在下一帧之前的空闲时间去触发垃圾回收。

  

  在《综合性 GC 问题和优化》一文中提到,绝大部分的 GC 引发的问题会表现在 CPU 上,而本质上这类问题却是 GC 引起的内存问题。

  一般产生的流程是:先在堆内存不断达到触发 GC 的预设条件,然后不断触发 GC,最后 CPU 飙高。

 

参考资料:

Node.js 环境性能监控探究

Nodejs中的内存管理和V8垃圾回收机制

深入 Nodejs 源码探究 CPU 信息的获取与实时计算

“译”Orinoco: V8的垃圾回收器

Node.js 调试指南

CPU负载和 CPU使用率

CPU负载

Difference between ‘self’ and ‘total’ in Chrome CPU Profile of JS

Deep understanding of chrome V8 garbage collection mechanism

怎么获取Node性能监控指标?获取方法分