调试node.js应用的内存泄漏[译]

原文:http://www.toptal.com/nodejs/debugging-memory-leaks-node-js-applications

有一次,我开着一辆内部是V8双涡轮增压发动机的奥迪,其性能令人难以置信。凌晨3点,我行驶在芝加哥附近没有人的IL-80高速公路,时速达到140MPH。从那时起,“V8”这个术语成为我对高性能的认识。

Node.js是一个建立在Chrome的JavaScript引擎V8之上的易于快速构建可伸缩的网络应用的平台。

尽管奥迪的V8非常强大,但仍然限于用你的油箱的容量大小。这同样适用于谷歌的V8引擎 - 在node.js背后的JavaScript引擎。有很多因素使Node.js的性能是难以置信的,适用于许多应用案例,但是你总是受限于内存堆的大小。当你的Node.js应用程序需要处理更多的请求时,你有两个选择:就是规模垂直或水平扩展。水平扩展意味着你必须运行多个并发的应用程序实例。如果做得对,你最终能够服务更多的请求。垂直扩展意味着你要为你的应用实例提高应用程序的内存使用情况和可用的性能或增加资源。

http://assets.toptal.io/uploads/blog/image/91676/toptal-blog-image-1442927311924-3c41bb7d7b1a8306609bcbe5570ccc63.jpg

最近有为我的Toptal(一个开放外包的平台)客户修复一个运行在Node.js上应用程序的内存泄漏问题。该应用程序是一个能够在每一分钟处理成千上万请求的API服务器。最初的应用几乎占据了600MB的RAM,所以我们决定采取热API端来重新实现它们。当你需要服务于许多请求开销变得非常昂贵。

对于新的API,我们选择的restify以及本地的MongoDB的驱动程序和Kue后台服务。听起来像是一个非常轻量级的栈,对不对?其实不然。在高峰负荷新的应用程序实例可以消耗高达270MB的RAM。因此起两个每1X的Heroku Dyno应用实的想法破灭了。

Node.js 内存泄露调试军火库

Memwatch

如果你搜索“如何在node里发现泄露”这个第一个你或许会搜到的工具就是memwatch。原始的包很久以前就被遗弃,不再保留。但是你可以很容易地找到在GitHub上的fork的库的更新版本。该模块是有用的,如果它看到堆增长超过5个连续的垃圾收集,它可以触发泄漏事件。

Heapdump

伟大的工具,它允许开发人员使用Chrome开发人员工具对Node.js采取堆快照后对其进行检查。

Node-inspector

即使是被更为有用的heapdump替代,但它任然可以让你连接到运行的应用程序,采取堆转储,甚至调试,并重新编译在程序运行中。

针对“node-inspector” 的说明

不幸的是,将不能连接到被在Heroku运行生产的应用中,因为不允许信号被发送到运行的进程。然而,Heroku的是不是唯一的托管平台。

为了在实战中体验node-inspector,我们将使用的RESTify编写一个简单的Node.一点点内存泄漏的根源放在js应用程序中。这里所有的实验都是用Node.js v0.12.7和已编译的V8 v3.28.71.19。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var restify = require('restify');
var server = restify.createServer();
var tasks = [];
server.pre(function(req, res, next) {
tasks.push(function() {
return req.headers;
});
// Synchronously get user from session, maybe jwt token
req.user = {
id: 1,
username: 'Leaky Master',
};
return next();
});
server.get('/', function(req, res, next) {
res.send('Hi ' + req.user.username);
return next();
});
server.listen(3000, function() {
console.log('%s listening at %s', server.name, server.url);
});

这个应用有个很简单且明显的内存泄漏。这个tasks的数组在应用的生命周期内引发变慢,并最终导致崩溃。这个问题是,不仅仅是闭包泄漏,而是波及了整个请求对象。

在V8里使用了stop-the-world这种GC策略,因此,这意味着越多的对象在内存中驻留,回收的时间就越长。在下面的日志你可以清楚地看到,在应用程序的生命的开始,平均回收内存是20毫秒,但几十万的请求后,则需要大约230ms。如果这时访问我们的应用程序将不得不等待230ms之久,因为正在GC。你也可以看到每隔几秒钟调用GC,这意味着每隔几秒钟,用户访问我们的应用程序会遇到问题。随着延迟时间增长,直到程序崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].
[28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].
[28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].
[28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].
...
[28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].
[28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].
[28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].

当在启动一个Node.js的应用时加上–trace_gc这个标示,就可以逐行打印这些日志。

1
node --trace_gc app.js

假设我们已经在这个Node.js的应用设置标示。在连接node-inspector到应用程序之前,我们需要把SIGUSR1信号发送到正在运行的进程。如果在群集中运行Node.js,请确保您连接到其中一个从属进程。

1
kill -SIGUSR1 $pid # Replace $pid with the actual process ID

通过这样做,我们正在让Node.js的应用(准确地说是V8)进入调试模式。在这种模式下,应用自动将打开的端口5858V8调试协议

我们下一步运行node-inspector连接到运行应用的调试接口,并打开8080端口的web接口

1
2
3
$ node-inspector
Node Inspector v0.12.2
Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.

如果应用程序在生产中运行,并且有一个防火墙,我们可以通过隧道远程端口8080到本地主机:

1
ssh -L 8080:localhost:8080 admin@example.com

现在,你可以打开你的Chrome网络浏览器即可获得完全的访问连接到远程生产应用程序的Chrome开发工具。不幸的是,Chrome开发人员工具将不能在其他浏览器。

让我们发现泄漏

在V8内存泄漏是不是真正的内存泄漏就像我们从C / C++应用程序的理解的那样。在JavaScript变量为空值时并不消失,他们只是“被遗忘”。我们的目标是要找到这些被遗忘的变量,并使这些内存释放。

在Chrome开发工具里我们有多个profiler。我们在记录堆分配而运行,并随着时间的推移需要多堆快照特别感兴趣。这给了我们一个明确的窥视到哪些对象正在泄漏。

开始录制堆分配,让我们模拟使用Apache基准在我们的主页上50个并发用户。

http://assets.toptal.io/uploads/blog/image/91677/toptal-blog-image-1442927477717.3-7c74dfceddd0955f27de9129eedf4261.jpg

1
ab -c 50 -n 1000000 -k http://example.com/

在建立新的快照之前,V8将执行标记 - 清除垃圾收集,所以我们肯定知道有没有旧垃圾中的快照。

修复运行中的泄漏

在收集堆分配快照3分钟后,我们得到类似下面的结果

http://assets.toptal.io/uploads/blog/image/91678/toptal-blog-image-1442927547865.5-d0c4450569bc2a285b3007eca57dfa25.jpg

我们可以清楚地看到,在堆里面有一些巨大的数组,很多IncomingMessage,ReadableState,ServerResponse和域对象。让我们分析下泄漏源。

在从20秒到40秒选择堆差异的图表中,我们只看到它是20秒后开始查看对象。这样,您就可以排除出所有正常的数据。

保持关注每种类型的对象在系统中的数目,我们扩大过滤从20多岁到1分钟。我们可以看到数组已经相当大了,并持续增长。在“(array)”,我们可以看到有很多的对象“(object properties)”具有同等的距离。这些对象是我们的内存泄漏的源头。

此外,我们也可以看到,“(closure)”的对象快速增长。

这可能是很方便的看这些字符串。根据字符串列表中也有很多的“Hi Leaky Master”的短语。这些可能给我们一些线索了。

在我们的例子中,我们知道,字符串“Hi Leaky Master”只能根据“GET/”路由进行组装。

果你打开从属的路径,你会看到这个字符串是通过req莫名其妙地引用,那么就创建的上下文的闭包把这一切都加入到巨型数组里。

http://assets.toptal.io/uploads/blog/image/91679/toptal-blog-image-1442927578118.2-af56af07dcf63ead26df3f9b9cd1aa78.jpg

所以在这一点上,我们知道有一些闭包的巨大数组。让我们通过这些闭包真正实时的得到源标签里面的名字。

http://assets.toptal.io/uploads/blog/image/91680/toptal-blog-image-1442927612747.1-102c5cf7bb68bcfc12181ab69333a520.jpg

在我们编辑完代码之后,在运行期内我们能按CTRL+S来保存和重新编译代码!

现在我们再来另外录一个堆分配的快照来看下闭包引起的内存保留。

很明显,那些SomeKindOfClojure()就是我们的问题所在。SomeKindOfClojure()闭包正在添加到一些命名任务到全局空间的数组。

很容易看出,这个数组仅仅是没有用处的。我们可以把它注释掉。但是,我们如何释放已被占用的内存?很容易,我们只分配一个空数组到任务中,然后下一个请求会被覆盖,再则内存将在下一个GC事件之后被释放。

http://assets.toptal.io/uploads/blog/image/91681/toptal-blog-image-1442927629279.4-24b223edd5e9b530625bd595fc50c4d1.jpg

多比自由啦!(出处《哈利波特》)

V8的垃圾回收的生命周期

http://assets.toptal.io/uploads/blog/image/91682/toptal-blog-image-1442928276284-e3da8ab85a898674f8f39088768b617a.jpg

那么,V8 JS本身并没有内存泄漏,只有被遗忘的变量。

V8堆被分成几个不同的空间:

新空间:这个空间相对较小,并且具有1MB-8MB的大小。多数的对象都在这里分配。

  • 老的指针空间:有可能有指向其他对象的对象。如果对象存活足够长的新空间也提升为老的指针空间。
  • 老的数据空间:只包含原始数据如字符串,封装的数据和未封装的doubles的数组。在新的空间存在的GC的时间足够长的对象迁移到这里。
  • 大对象空间:它的对象是太大,以适应在其他空间都在这个空间中创建。每个对象都有它自己的内存mmap’ed的区域
  • 代码空间:包含由JIT编译器生成的汇编代码。
  • 单元空间,属性单元空间,map空间:这个空间包含单元,属性单元和Map。这用于简化垃圾收集。

每个空间是由页面组成的。一个页面是内存从操作系统的mmap分配的区域。每一页始终是1MB大小,除了大对象空间的页面。

V8有两个内置的垃圾收集机制:清扫,标记清扫和标记压缩。

清扫是一个非常快的垃圾回收技术,在新的空间进行对象操作。清扫的一种实现Cheney’s Algorithm。概念非常简单,新空间被分成两个相等的各半个空间:到空间和从空间。当去空间满了就发起清扫的GC。它只是从空间和所有活动对象复制到从空间或将其提升到旧空间之一,如果他们经历了两次清扫,然后被完全从空间删除。可清除速度非常快但是他们保持双大小的堆不断在内存中拷贝对象的开销。使用可清除的原因是因为大多数对象早期被回收。

标记清扫和标记压缩是另一种V8垃圾回收的控制方式。另一种满垃圾收集器。它标志着所有活节点,然后清洗全部死的节点并重新整理内存。

GC性能和调试贴士

虽然Web应用程序的高性能可能不是个大问题,你还是会希望不惜一切代价避免泄漏。在full GC的标记阶段的应用程序实际上已暂停,直到垃圾收集完成。这意味着越多的对象在堆中时间越长,执行GC时,越长的用户等待时间。

总是给闭包和函数命名

所有的闭包和功能有名字的话,很容易检查跟踪堆栈。

1
2
3
db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) {
...
})

避免在热函数上使用大对象

理想情况下,你要避免热函数内部的大对象,把所有的数据装配到新的空间。所有的CPU和内存相关的操作应在后台执行。此外,还要避免触发热功能不优化,优化的热函数使用较少的内存比非优化的。

热函数应该被优化

Hot functions that run faster but also consume less memory cause GC to run less often. V8 provides some helpful debugging tools to spot non-optimized functions or deoptimized functions.
如果热函数消耗更少的内存从而减少GC的频率,这样运行速度更快。 V8提供了一些有用的调试工具,以发现没有优化的函数或非优化的函数。

避免多态性IC的热函数

内部缓存(IC)用于加快执行的一些代码块中,要么通过缓存对象属性访问obj.key或一些简单函数。

1
2
3
4
5
6
7
function x(a, b) {
return a + b;
}
x(1, 2); // monomorphic
x(1, “string”); // polymorphic, level 2
x(3.14, 1); // polymorphic, level 3

x(a,b)第一次运行时,V8创建了一个单态的内部缓存。当你调用x一秒钟,V8移除老的IC,创建一个新的多态的IC来支持整形和字符串的操作。当你再次调用IC,V8重复以上步骤创建了另一个第三级的多态IC。、

然而,层级是有限的。在IC的层级达到5级时(可以改变–max_inlining_levels标示),这个函数将成为多态并且不再可被优化的。

这里直观的理解了单态函数运行速度最快的,也消耗更小的内存占用。

不要把大对象加入内存

这一个是显而易见的,众所周知的。如果具有大的要处理的文件,例如一个大的CSV文件,一行一行的读取并小块处理而不是加载整个文件存储器到内存中。在极少的情况下,CSV单行将超过1MB,才会新分配的空间存放。

不要阻塞主服务线程

如果你有一些热API需要一些时间来处理,像一个缩放图片的API,单独把它放到一个后台的线程的服务里。CPU密集型操作会阻塞主线程强制所有其他客户等待,保持发送中请求。未处理的请求数据就堆在内存中,从而迫使完整的GC需要更长的时间来完成。

不要创建不必要的数据

我曾经在使用restify有一个奇怪的经历。如果您发送几十万请求无效的URL,那么应用程序的内存将迅速增长就高达百兆字节,直到一个full GC几秒钟后,一切都会恢复正常。原来,为每个无效的URL,restify会生成一个新的错误对象,其中包括长的堆栈跟踪。这迫使新创建的对象被分配在大对象空间里,而不是新生代空间。

获得这样的数据可以在开发过程中非常有帮助的,但显然生产环境中不需要。因此,规则很简单 - 不生成数据,除非你确实需要它。

了解你的工具

最后,但当然不是最不重要的,是要了解你的工具。有各种调试器,泄漏cathers和使用情况的图形生成器。所有这些工具可以有助于你的软件更快,更高效。

结论

理解V8的垃圾回收和代码优化如何工作以及应用性能的关键。V8对JavaScript编译到本地的执行在某些情况下编写的代码实现性能与GCC编译的应用程序相媲美。

而如果你想知道,我的Toptal客户新的API应用程序,虽然还改进的余地,但是已经工作得非常好了!

Joyent公司最近发布的Node.js的新版本,它使用的V8引擎是最新版本之一。针对Node.js的v0.12.x写了一些应用程序可能无法与新的4.x版版本兼容。然而,应用程序将在node.js中的新版本出现了巨大的性能和内存使用情况的改善.