之前 TMS 在运行时 CPU 中占用率和内存占用一直很高,导致应用运行状态不是很良好,需要频繁重启。经过排查,找出了部分原因:
使用的 html-minifier 模块有问题,如果输入的内容是一个有错误的 HTML 结构,会使解析进入死循环,导致 CPU 占用率 100%。
在使用 vm 模块时,使用姿势错误,导致内存占用无法释放,使内存占用暴涨。
第一个问题我们今天不予讨论,主要来说一下第二个问题。
我们就先了解下 VM 这个模块。
从它的名字和暴露的 API 可以看出,它能创建一个拥有指定上下文的运行环境,可以在里面直接运行 JavaScript 代码,类似 eval
。这样运行代码时,不会污染当前作用域,一旦出问题,也不会对当前环境造成很大影响。
虽然这个模块我们平时用的比较少,但它算是 Node.js 的核心模块,在 require
的实现中,你会发现它的身影。我们在使用 Node.js 时,会使用 require
引入很多外部模块,对于 Node.js 来说,我们引入的代码如果直接和运行环境交互,是十分危险的。所以在 Node.js 模块加载的过程中,会先将 .js
文件的内容进行包裹,变成类似 function(...) {}(...)
的形式,然后使用 vm.runInThisContext
去运行,同时将 module、require 等方法传入返回的函数中。具体的模块加载机制,可以在 lib/module.js 中看到实现,不是本文重点,就不细说了。
当然,我们也可以用它来执行我们的代码:
const vm = require('vm'); const code = 'result = 2 * n;'; const script = new vm.Script(code); // 预编译后供之后使用 const sandbox = { n: 5 }; const _sandbox = { n: 10 }; const ctx = vm.createContext(_sandbox); // contextify // 供 runInThisContext 使用 global.result = 0; global.n = 16; // 在当前上下文运行,32 vm.runInThisContext(code); script.runInThisContext(); // 在新的上下文中运行,10 vm.runInNewContext(code, sandbox); script.runInNewContext(sandbox); // 在执行上下文中运行,20 vm.runInContext(code, _sandbox); script.runInContext(_sandbox); |
在 TMS 中,需要压缩用户上传的代码,出于安全和稳定的考虑,需要和当前运行环境进行隔离,这里就可以使用 VM 模块。为了便于理解,简化了一个类似的 Demo,如下:
// fibonacci,计算斐波纳挈数列 http.createServer(function(req, res) { let sandbox = { fibonacci: fibonacci, number: 10 }; vm.runInNewContext('a = fibonacci(number)', sandbox); res.end(); }).listen(8999, '127.0.0.1'); |
运行 Demo。为了模拟实际环境中的并发,这里我们使用 ab
来发起请求。
ab -n 1000 -c 100 http://127.0.0.1:8999/
|
Apache HTTP server benchmarking tool,简称 ab,是一个常用的开源网站压力测试工具,官网。
在运行期间,我们使用 top
来观察内存的占用情况。
可以发现一些问题,
Demo 应用比较简单,引发的问题不大。但如果在实际的应用场景中,一旦发生内存占用过高,无法分配内存空间的情况,会对应用稳定性照成很大影响,甚至导致应用崩溃。
接下来,我们再看一个例子,将上面的代码稍作修改,如下:
let sandbox = { fibonacci: fibonacci, number: 10 }; http.createServer(function server (req, res) { vm.runInNewContext('a = fibonacci(number)', sandbox); res.end(); }).listen(8999, '127.0.0.1'); |
用上面同样的方法观察,结果如下图:
这次,我们看到内存仅占用了 19M,而且增长很平缓,QPS 提高了不少。
仅仅是声明 sandbox 位置的不同,差别却如此之大,为什么呢?
我们都知道,一般一个在函数中声明的变量,在函数运行完,就会被释放掉,所占用的空间也会被回收。但在之前的例子,很有可能 sandbox 变量没有被回收,导致的内存暴涨。它和其它变量有什么区别,导致它不能被正确释放呢?
翻了下 vm 的代码,发现在使用 vm.runInNewContext
时,会将你传入的 sandbox 进行 contextify,问题可能就出在这里。
contextify 大体流程如下(src/node_contextify.cc#L281 MakeContext):
ContextifyContext
实例,并且挂载到 sandbox 的_contextifyHidden 属性上。 如果我们用一个在函数外部声明的 sandbox,如同第二种写法,那么无论我们调用多少次 runInNewContext,都只会进行一次 contextify 操作,效果类似于 vm.runInContext
。但是,如果像第一种写法那样,每次都使用一个新的对象,那么每次都要进行 contextify,而 contextify 过程中比较关键的一步是创建一个 ContextifyContext
实例,这个类有些特殊的地方,我们看下它的具体定义(在 src/node_contextify.cc#L49 ):
class ContextifyContext { ... Persistent Persistent |
它里面有三个被声明成 Persistent
类型的变量,重点就在这里。
在 V8 中,有三种概念, Handle 、 Local 、 Persistent 。所有 JavaScript 数据都是有 GC 管理的。JavaScript 中的变量在 C++ 层面都是和 Handle 对应的,可以把它理解成一个普通的指针,用来指向数据的内存位置。而 Local 可以看做一个实际存储数据的空间,拥有 new 方法,当它 new 出来后,无论是否有变量接收,都会存在于 HandleScope中。 HandleScope 可以理解成一个管理和回收 Handle 的东西。所以,一个 Local 可以有多个 Handle 指向。而 HandleScope 类似于函数的作用域,它管理着 Handle 和 Local,一旦 HandleScope 退出,其上的 Handle 和 Local 就会被释放掉,可以联系 JavaScript 中的函数作用域来理解。
如同 JavaScript 中的闭包一样,我们有时会需要一种在函数退出后依然存在的变量,这就是 Persistent 类型,它不由 HandleScope 管理,只要没有手动释放,它就一直可以被使用。可以简单用堆和栈的概念来理解,Persistent 是堆变量,HandleScope 是栈,Local 是栈变量,而 Handle 是一种引用。
对于 Persistent 类型变量,除了手动调用 Dispose()
释放外,V8 还提供了一种自动的,依赖 GC 的释放方式,就是 Weak Callback + MarkIndependent
的组合,显然,Node.js 就是使用的这种。这种方式的优势在于自动化,不用开发者去管理这部分内存,但是过分的依赖 GC,难免会产生各种各样的问题,比如:内存释放不及时,占用过多系统资源等。
要知道,GC 并不是实时的,它是需要程序停下来一段时间来让它来进行回收操作的,如果程序一直在运行,那么 GC 操作就会被延后,直到它觉得必需要运行的时候。这样,会造成要释放资源的积压。如果频繁执行 GC,则会影响程序的运行效率。
而且,Weak Callback
的执行是由 GC 决定的,一般是在 Full GC 前后。比较过分的是,GC 不保证一定会调用这个回调。。。
另外,在上述的场景中,通过试验,可以做这样的猜想:因为 old space 默认大小为 1G,而我们看到在 1000 次执行完后,old space 才 800M 左右,没有达到阈值,所以 V8 并不会处理这部分的内存占用。当我们把 old space 设为 200M 时,其值稳定在 180M 左右,可以大体印证这个猜想。
综上,问题的根源找到了。每次请求回调里都会创建一个新的 sandbox,并且它不能在使用完后立即释放,于是就形成很多无用的 Persistent Handle,堆积在内存中,导致内存占用暴涨。而且,它们的释放主要依赖于 MarkSweep,执行频率不高,所以占用释放很慢。可以想象,在一个高 QPS 的应用下,内存基本上是只增不降的,一点点被蚕食干净。
问题既然找到了,那么就来看下如何解决。
把 sandbox 在回调外面声明,减少重复 contextify。因为脚本运行所需要的 context 对象实际上就是 sandbox 对象,只是在底层标识了一下(_contextifyHidden),这一点在 MakeContext 函数中以及获取 vm 里的返回值时可以看出来,所以修改 sandbox 的值即可以实现传递不同参数的效果。
let sandbox = { fibonacci: fibonacci, number: 10 }; http.createServer(function(req, res) { // 传递不同的值 sandbox.number = Math.floor(Math.random() * 20); vm.runInNewContext('a = fibonacci(number)', sandbox); res.end(); }).listen(8999, '127.0.0.1'); |
vm 模块本身提供了复用的能力,Script
和 createContext
,所以可以利用它们来处理。
const code = 'a = fibonacci(number)'; const script = new vm.Script(code); let sandbox = { fibonacci: fibonacci, number: 10 }; let ctx = vm.createContext(sandbox); http.createServer(function(req, res) { sandbox.number = Math.floor(Math.random() * 20); script.runInContext(ctx); res.end(); }).listen(8999, '127.0.0.1'); |
从上面 contextify 的过程中,我们除了可以发现 context 和 sandbox 是关联的之外,还有一点就是 runInNewContext
会对 sandbox 做校验,所以这里使用 runInNewContext
也不会有上述的问题。
这种方案更有普适性,不一定针对于这个问题本身。
Node.js 本身提供了很多关于 GC 方面的参数。
MarkSweep,Full GC 的标记阶段
--trace_gc
,打印 GC 日志--expose-gc
,暴露 GC 方法,可以手动调用 global.gc() 来强制执行 GC 过程,并不推荐使用。--max-new-space-size
,最大 new space 大小,执行 scavenge 回收,默认 16M,单位 KB--max-old-space-size
,最大 old space 大小,执行 MarkSweep 回收,默认 1G,单位 MB--gc-global
,强制每次执行 MarkSweep。 可以通过调节这些参数的配置,观察 GC 日志中 sweeping from
(内存积压状况)、Mark-sweep
(MarkSweep 用时)等,来优化 GC 过程,需要一定的耐心。当然,有些值不能太极端,比如把 --max-old-space-size
设置的很小,频繁触发 GC,会导致应用的执行效率下降。
以后如果遇到一些性能问题,我们该如何去排查呢?这里介绍一些常用的方法。
使用 V8 自带的 profiler 功能,分析 JavaScript 各个函数的消耗和 GC 部分。
npm install profiler node --prof xxx.js |
会生成 xxxx-v8.log,之后使用工具转换成可读的。
npm install tick node-tick-processor xxxx-v8.log |
就可以查看相关的数据了。
这个工具就不多介绍了,大家应该很熟了,它可以使用 Chrome 开发者工具来调试 Node.js 应用。
它可以对 Node.js 应用进行 heapdump。然后,可以使用 Chrome 开发者工具打开生成的 xxx.heapsnapshoot 文件,查看 heap 中的内容。
npm install heapdump
|
在应用中引入
var heapdump = require('heapdump');
|
执行一段时间后退出,或者在命令行中:
kill -USR2 |
这个被 node-inspector 集成了,可以提供 HeapDump 和 CPU Profile 功能。
详见 v8-profiler。
可以帮助发现代码存在的内存泄露问题,也可以做在不同时间点堆的比较。
详见 node-memwatch
当然,工具只是辅助作用,在平时写代码时多思考一下,善用 API,在处理问题时多积累些经验,才能写出更好的代码。
V8 提供的内存释放方案有它的优势所在,但 GC 是个很复杂的过程,过分依赖自动化,也不一定是好事。特别在写 Node.js 底层的 C++
部分时,我们还是要考虑下是否该手动释放的问题,不要把问题都抛给 V8。当然,对于 API 应用也要注意,本身 VM 模块提供了更好的方案,但我们却忽略了。
V8 比较复杂,理解有误的地方,欢迎指正,讨论。
参考资料:
http://taobaofed.org/blog/2016/01/14/nodejs-memory-leak-analyze/