个性化阅读
专注于IT技术分析

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

本文概述

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

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

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

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

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

鸣叫

最近, 有人要求我为我的srcmini客户端之一开发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检查它们。

节点检查器

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

以”节点检查器”为例

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

为了体验Node-inspector的实际效果, 我们将使用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个并发用户。

屏幕截图
ab -c 50 -n 1000000 -k http://example.com/

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

解决泄漏问题

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

屏幕截图

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

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

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

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

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

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

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

屏幕截图

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

屏幕截图

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

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

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

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

屏幕截图

多比是免费的!

V8中的垃圾生活

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

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

鸣叫

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

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

每个空间都由页面组成。页面是使用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, 从而使你可以将其放入新空间。

不阻塞主服务器线程

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

不要创建不必要的数据

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

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

了解你的工具

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

总结

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

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

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

赞(0)
未经允许不得转载:srcmini » 调试Node.js应用程序中的内存泄漏

评论 抢沙发

评论前必须登录!