h调试Node.js应用程序中的内存泄漏

注意:我最初在Toptal博客上发布了这篇文章。

我曾经驾驶装有V8双涡轮增压引擎的奥迪,其性能令人难以置信。 凌晨3点,我在芝加哥附近的IL-80高速公路上以大约140MPH的速度行驶,当时没有人在路上。 从那时起,“ V8”一词对我而言已经与高性能相关联。

Node.js是基于Chrome的V8 JavaScript引擎构建的平台,可轻松构建快速且可扩展的网络应用程序。

尽管奥迪V8非常强大,但油箱的容量仍然受到限制。 Google的V8也是如此(Node.js背后的JavaScript引擎)。 它的性能令人难以置信,并且有很多原因使Node.js 在许多用例中都能很好地工作 ,但是总是受堆大小限制。 当您需要在Node.js应用程序中处理更多请求时,有两种选择:垂直缩放或水平缩放。 水平扩展意味着您必须运行更多的并发应用程序实例。 正确完成后,您最终可以满足更多请求。 垂直扩展意味着必须提高应用程序的内存使用率和性能,或增加可用于应用程序实例的资源。

h调试Node.js应用程序中的内存泄漏

调试Node.js应用程序中的内存泄漏

最近,有人要求我为我的Toptal客户端之一开发Node.js应用程序,以解决内存泄漏问题。 该应用程序是一个API服务器,旨在每分钟处理数十万个请求。 原始应用程序占用了将近600MB的RAM,因此我们决定采用热API端点并重新实现它们。 当您需要处理许多请求时,开销会变得非常昂贵。

对于新的API,我们选择使用本机MongoDB驱动程序和Kue重新进行后台工作。 听起来像是一个非常轻巧的堆栈,对吗? 不完全的。 在高峰负载期间,新的应用程序实例可能会消耗多达270MB的RAM。 因此,我每1X Heroku Dyno拥有两个应用程序实例的梦想消失了。

Node.js内存泄漏调试工具库

记忆手表

如果您搜索“如何找到节点中的泄漏”,那么您可能会发现的第一个工具是memwatch 原始包装很久以前就被放弃了,不再维护。 但是,您可以在GitHub的存储库fork列表中轻松找到它的较新版本。 此模块很有用,因为如果看到堆超过5个连续的垃圾回收,它将发出泄漏事件。

堆转储

出色的工具,可让Node.js开发人员获取堆快照,并在以后使用Chrome Developer Tools检查它们。

节点检查器

甚至是堆转储的一种更有用的替代方法,因为它使您可以连接到正在运行的应用程序,进行堆转储,甚至可以即时调试和重新编译它。

以“ node-inspector”为例

不幸的是,您将无法连接到在Heroku上运行的生产应用程序,因为它不允许将信号发送到正在运行的进程。 但是,Heroku不是唯一的托管平台。

为了体验运行中的节点检查器,我们将使用restify编写一个简单的Node.js应用程序,并在其中添加一些内存泄漏源。 此处的所有实验都是使用针对V8 v3.28.71.19编译的Node.js v0.12.7进行的。

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);
});

这里的应用程序非常简单,并且泄漏非常明显。 阵列任务将在应用程序生命周期内增长,从而导致速度变慢并最终崩溃。 问题在于我们不仅在泄漏闭包,而且在泄漏整个请求对象。

V8中的GC采用“停世界”策略,因此,它意味着内存中存储的对象越多,收集垃圾所花费的时间就越长。 在下面的日志中,您可以清楚地看到,在应用程序生命周期的开始阶段,收集垃圾的平均时间为20ms,但数十万次请求之后,大约需要230ms。 由于GC,尝试访问我们的应用程序的人现在必须等待230ms以上。 您还可以看到每隔几秒钟就会调用一次GC,这意味着每隔几秒钟用户就会在访问我们的应用程序时遇到问题。 直到应用程序崩溃,延迟都会增加。

[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].

当使用–trace_gc标志启动Node.js应用程序时,将打印以下日志行:

node --trace_gc app.js

让我们假设我们已经使用此标志启动了Node.js应用程序。 在将应用程序与node-inspector连接之前,我们需要将SIGUSR1信号发送给正在运行的进程。 如果在群集中运行Node.js,请确保连接到其中一个从属进程。

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

通过这样做,我们使Node.js应用程序(准确地说是V8)进入调试模式。 在此模式下,应用程序会使用V8调试协议自动打开端口5858。

下一步是运行node-inspector,它将连接到正在运行的应用程序的调试界面,并在端口8080上打开另一个Web界面。

$ 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隧道传输到localhost:

ssh -L 8080:localhost:8080 [email protected]

现在,您可以打开Chrome网络浏览器,并获得对远程生产应用程序附带的Chrome开发工具的完全访问权限。 不幸的是,Chrome开发者工具无法在其他浏览器中使用。

让我们找到泄漏!

正如我们从C / C ++应用程序了解的那样,V8中的内存泄漏不是真正的内存泄漏。 在JavaScript中,变量不会消失在空白中,只会被“遗忘”。 我们的目标是找到这些被遗忘的变量,并提醒他们Dobby是免费的。

在Chrome开发者工具内部,我们可以访问多个探查器。 我们对记录堆分配特别感兴趣,它可以运行并随时间拍摄多个堆快照。 这使我们可以清楚地看到哪些对象正在泄漏。

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

h调试Node.js应用程序中的内存泄漏
ab -c 50 -n 1000000 -k http://example.com/

在获取新快照之前,V8将执行标记清除垃圾收集,因此我们肯定知道快照中没有旧垃圾。

解决泄漏问题

3分钟的时间内收集了堆分配快照之后,我们最终得到如下内容:

h调试Node.js应用程序中的内存泄漏

我们可以清楚地看到堆中还有一些巨大的数组,很多IncomingMessage,ReadableState,ServerResponse和Domain对象。 让我们尝试分析泄漏的来源。

在图表上选择从20s到40s的堆差异时,我们将仅看到从启动分析器开始20s之后添加的对象。 这样,您可以排除所有正常数据。

记下系统中每种类型有多少个对象,我们将过滤器从20s扩展到1min。 我们可以看到,已经非常庞大的阵列不断增长。 在“(数组)”下,我们可以看到有许多等距的对象“(对象属性)”。 这些对象是我们内存泄漏的根源。

我们还可以看到“(关闭)”对象也迅速增长。

同时查看字符串可能也很方便。 在字符串列表下,有很多“ Hi Leaky Master”短语。 这些也可能给我们一些线索。

在我们的案例中,我们知道字符串“ Hi Leaky Master”只能在“ GET /”路径下进行组装。

如果打开保留器路径,您将看到此字符串通过req以某种方式引用,那么将创建上下文,并将所有这些添加到一些巨大的闭包数组中。

h调试Node.js应用程序中的内存泄漏

因此,在这一点上,我们知道我们有一些巨大的闭包数组。 实际上,让我们在“源”选项卡下实时为所有闭包命名。

h调试Node.js应用程序中的内存泄漏

完成代码编辑后,我们可以按CTRL + S来快速保存和重新编译代码!

现在,让我们记录另一个堆分配快照,并查看哪些关闭正在占用内存。

很明显SomeKindOfClojure()是我们的恶棍。 现在我们可以看到SomeKindOfClojure()闭包正在添加到全局空间中一些名为task的数组中。

可以很容易地看出这个数组是没有用的。 我们可以将其注释掉。 但是,如何释放已占用的内存呢? 很简单,我们只给任务分配一个空数组,下一个请求将覆盖它,下一个GC事件后将释放内存。

h调试Node.js应用程序中的内存泄漏

多比是免费的!

V8中的垃圾生活

h调试Node.js应用程序中的内存泄漏

好吧,V8 JS没有内存泄漏,只有被遗忘的变量。

V8堆分为几个不同的空间:

  • 新空间 :此空间相对较小,大小在1MB至8MB之间。 大多数对象都分配在这里。
  • 旧指针空间 :具有可能包含指向其他对象的指针的对象。 如果对象在新空间中生存足够长的时间,它将被提升为旧指针空间。
  • 旧数据空间 :仅包含原始数据,例如字符串,装箱的数字和未装箱的双精度数组。 GC中在新空间中存活时间足够长的对象也将移到这里。
  • 大对象空间 :在此空间中创建了太大而无法容纳其他空间的对象。 每个对象在内存中都有自己的mmap区域
  • 代码空间 :包含由JIT编译器生成的汇编代码。
  • 单元格空间,属性单元格空间,地图空间 :此空间包含CellPropertyCellMap 这用于简化垃圾收集。

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

V8具有两种内置的垃圾收集机制:Scavenge,Mark-Sweep和Mark-Compact。

Scavenge是一种非常快速的垃圾收集技术,可与New Space中的对象一起使用。 Scavenge是Cheney算法的实现 这个想法很简单,将New Space划分为两个相等的半空间:To-Space和From-Space。 当“收尾空间”已满时,将进行清理GC。 它只是交换To和From空间,并将所有活动对象复制到To-Space或将它们提升到旧空间之一(如果它们在两次清理中幸存下来),然后从空间中完全删除。 清除速度非常快,但是它们的开销是保持两倍大小的堆并不断在内存中复制对象。 使用清理的原因是因为大多数对象都死得很年轻。

Mark-Sweep和Mark-Compact是V8中使用的另一种垃圾收集器。 另一个名称是完整的垃圾收集器。 它标记所有活动节点,然后清除所有无效节点并整理内存碎片。

GC性能和调试提示

尽管对于Web应用程序而言,高性能可能不是一个大问题,但您仍将不惜一切代价避免泄漏。 在完全GC的标记阶段,实际上将暂停应用程序,直到完成垃圾回收为止。 这意味着您在堆中拥有的对象越多,执行GC所需的时间就越长,用户必须等待的时间也就越长。

总是给闭包和函数起名字

当所有闭包和函数都有名称时,检查堆栈跟踪和堆就容易得多。

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

避免热功能中的大物体

理想情况下,您要避免在热函数内放置大型物体,以使所有数据都适合“ 新空间” 所有CPU和内存绑定操作应在后台执行。 还应避免针对热功能进行非优化触发器,优化的热功能使用的内存要少于未优化的内存。

热门功能应进行优化

热功能运行速度更快,但占用的内存更少,导致GC的运行频率降低。 V8提供了一些有用的调试工具,可以发现未优化的功能或未优化的功能。

避免热功能中的IC多态

内联高速缓存(IC)用于通过缓存对象属性访问obj.key或某些简单函数来加快某些代码块的执行速度。

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创建单态IC。 当您第二次调用x时,V8会擦除旧的IC并创建一个新的多态IC,该IC同时支持整数和字符串这两种类型的操作数。 当您第三次调用IC时,V8重复相同的过程并创建另一个3级的多态IC。

但是,有一个限制。 IC级别达到5(可以使用–max_inlining_levels标志更改)后,该功能将变为大形的,不再被认为是可优化的。

从直觉上可以理解,单态函数运行最快,并且内存占用也较小。

不要将大文件添加到内存

这是显而易见的,众所周知的。 如果您有大文件要处理,例如大CSV文件,请逐行读取并分小块处理,而不是将整个文件加载到内存中。 在极少数情况下,单行csv会大于1mb,因此您可以将其放入New Space中

不阻塞主服务器线程

如果您有一些热的API需要花费一些时间来处理,例如调整图像大小的API,请将其移至单独的线程或将其转换为后台作业。 CPU密集型操作将阻止主线程,迫使所有其他客户等待并继续发送请求。 未处理的请求数据将堆积在内存中,从而迫使完整的GC需要更长的时间才能完成。

不要创建不必要的数据

我曾经在Restify方面有过很奇怪的经历。 如果您向无效的URL发送数十万个请求,则应用程序内存将迅速增长到数百兆,直到几秒钟后完整的GC启动,此时一切将恢复正常。 事实证明,对于每个无效的URL,restify都会生成一个新的错误对象,其中包含长堆栈跟踪。 这迫使新创建的对象分配在大对象空间而不是新空间中

在开发过程中访问此类数据可能会很有帮助,但显然在生产中并不需要。 因此,规则很简单-除非您确实需要,否则不要生成数据。

最后但并非最不重要的一点是了解您的工具。 有各种调试器,泄漏处理程序和使用图生成器。 所有这些工具都可以帮助您使软件更快,更高效。

结论

了解V8的垃圾回收和代码优化器的工作方式是应用程序性能的关键。 V8将JavaScript编译为本机程序集,在某些情况下,编写良好的代码可以获得与GCC编译应用程序相当的性能。

如果您想知道,我的Toptal客户端的新API应用程序尽管有改进的余地,但效果很好!

Joyent最近发布了新版本的Node.js,该版本使用了最新版本的V8。 为Node.js v0.12.x编写的某些应用程序可能与新的v4.x版本不兼容。 但是,在新版本的Node.js中,应用程序将获得巨大的性能和内存使用方面的改进。

最初在 www.toptal.com上 发布

From: https://hackernoon.com/debugging-memory-leaks-in-node-js-applications-84125c0530d2