作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
弗拉德·米勒的头像

Vlad Miller

Vlad是一位多才多艺的软件工程师,在许多领域都有经验. 他目前正在完善自己的Scala和机器学习技能.

Share

我曾经开过一辆装有V8双涡轮增压发动机的奥迪,它的性能令人难以置信. 凌晨3点,我在芝加哥附近的IL-80高速公路上以140英里每小时的速度行驶,路上一个人也没有. 从那时起,“V8”这个词对我来说就和高性能联系在一起了.

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

虽然奥迪的V8引擎非常强大,但你的油箱容量仍然有限. 谷歌的V8也是如此——Node背后的JavaScript引擎.js. 它的性能令人难以置信,有很多原因.js 适用于许多用例,但是你总是受到堆大小的限制. 当您需要在Node中处理更多请求时.在Js应用程序中,你有两种选择:垂直扩展或水平扩展. 水平扩展意味着您必须运行更多的并发应用程序实例. 如果处理得当,您最终能够服务更多请求. 垂直扩展意味着您必须改进应用程序的内存使用和性能,或者增加应用程序实例可用的资源.

调试节点内存泄漏.js应用程序

调试节点内存泄漏.js应用程序

最近我被要求开发一个Node.为我的一个Toptal客户修复内存泄漏问题. 应用程序, API服务器, 打算每分钟处理数十万个请求. 最初的应用程序占用了近600MB的RAM,因此我们决定采用热门API端点并重新实现它们. 当您需要处理许多请求时,开销会变得非常昂贵.

对于新的API,我们选择了使用本地MongoDB驱动程序的restify和用于后台任务的Kue. 听起来很轻巧,对吧? Not quite. 在峰值负载期间,一个新的应用程序实例可能会消耗高达270MB的RAM. 因此,我希望每1X Heroku Dyno拥有两个应用程序实例的梦想消失了.

Node.. js内存泄漏调试库

Memwatch

如果您搜索“如何查找节点中的泄漏”,您可能会找到的第一个工具是 memwatch. 原来的包很久以前就被抛弃了,不再维护. 但是你可以很容易地在GitHub上找到它的新版本 存储库的Fork列表. 这个模块很有用,因为如果它看到堆增长超过5个连续的垃圾收集,它可以发出泄漏事件.

Heapdump

很棒的工具 Node.js开发人员 以获取堆快照,并在稍后使用Chrome开发工具检查它们.

Node-inspector

这是一个比堆转储更有用的选择, 因为它允许您连接到正在运行的应用程序, 获取堆转储,甚至动态地调试和重新编译它.

以“node-inspector”为例

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

为了体验Node -inspector的作用,我们将编写一个简单的Node.Js应用程序使用restify,并在其中放置了一点内存泄漏源. 这里所有的实验都是用Node做的.js v0.12.7,它是针对V8 v3编译的.28.71.19.

Var restify = require('restify');

Var服务器= restify.createServer ();

Var tasks = [];

server.Pre (function(req, res, next) {
  tasks.推动(函数(){
    return req.headers;
  });

  //同步从会话获取用户,可能是jwt令牌
  req.user = {
    id: 1,
    用户名:“Leaky Master”
  };

  返回下一个();
});

server.Get ('/', function(req, res, next) {
  res.发送('Hi ' +请求.user.username);
  返回下一个();
});

server.Listen (3000, function() {
  console.日志('%s '正在监听'%s ',服务器.name, server.url);
});

这里的应用程序非常简单,但有一个非常明显的漏洞. The array tasks 是否会随着应用程序的生命周期而增长,导致它变慢并最终崩溃. 问题是,我们不仅泄漏了闭包,还泄漏了整个请求对象.

V8中的GC采用了停止世界策略, 因此,这意味着内存中的对象越多,收集垃圾所需的时间就越长. 在下面的日志中,您可以清楚地看到,在应用程序生命周期的开始阶段,收集垃圾的平均时间为20ms, 但是在几十万个请求之后,它大约需要230ms. 试图访问我们的应用程序的人将不得不等待 230ms 因为GC,现在更长了. 您还可以看到,GC每隔几秒钟调用一次,这意味着每隔几秒钟用户就会遇到访问应用程序的问题. 并且延迟会越来越长,直到应用程序崩溃.

[28093] 7644毫秒:标记-扫描.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap][请求旧空间的GC].

[28093] 7717毫秒:标记-扫描.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap][请求旧空间的GC].

[28093] 7866 ms:标记-扫描.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap][请求旧空间的GC].

[28093] 8001毫秒:标记-扫描.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap][请求旧空间的GC].

...

[28093] 633891毫秒:标记扫描235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap][请求旧空间的GC].

[28093] 635672毫秒:标记扫描235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap][请求旧空间的GC].

[28093] 637508毫秒:标记扫描235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap][请求旧空间的GC].

这些日志行打印时,节点.. Js应用程序是用 –trace_gc flag:

节点——trace_gc应用程序.js

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

kill -SIGUSR1 $pid #将$pid替换为实际进程ID

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

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

node-inspector美元
节点检查器v0.12.2
访问http://127.0.0.1:8080/?ws=127.0.0.1:8080&端口=5858,启动调试.

如果应用程序在生产环境中运行,并且您有防火墙, 我们可以将远程端口8080隧道到localhost:

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

现在,您可以打开Chrome web浏览器,并完全访问附加到远程生产应用程序的Chrome开发工具. 不幸的是,Chrome开发人员工具将无法在其他浏览器中工作.

让我们找一个漏洞!

V8中的内存泄漏并不像我们在C/ c++应用程序中所知道的那样是真正的内存泄漏. 在JavaScript中,变量不会消失在空白中,它们只是被“遗忘”了。. 我们的目标是找到这些被遗忘的变量,提醒他们多比是自由的.

在Chrome开发者工具中,我们可以访问多个分析器. 我们特别感兴趣的是 记录堆分配 哪个在一段时间内运行并获取多个堆快照. 这让我们可以清楚地看到哪些对象正在泄漏.

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

Screenshot

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

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

动态修复漏洞

在收集一段时间内的堆分配快照后 3 minutes 我们最终得到如下结果:

Screenshot

我们可以清楚地看到有一些巨大的阵列, 很多IncomingMessage, ReadableState, ServerResponse和Domain对象以及堆中的对象. 让我们试着分析一下泄漏的源头.

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

记录系统中每种类型的对象的数量, 我们将过滤时间从20s扩展到1min. 我们可以看到,已经相当巨大的阵列还在不断增长. 在“(array)”下,我们可以看到有很多相等距离的对象“(对象属性)”. 这些对象是内存泄漏的根源.

我们还可以看到“(闭包)”对象也在快速增长.

看一下字符串也会很方便. 在弦乐列表下面有很多“Hi Leaky Master”的短语. 这些可能也会给我们一些线索.

在我们的例子中,我们知道字符串“Hi Leaky Master”只能在“GET /”路由下组装.

如果你打开retainers路径,你会看到这个字符串以某种方式通过 req,然后创建上下文,并将所有这些添加到一些巨大的闭包数组中.

Screenshot

现在我们知道我们有一个庞大的闭包数组. 让我们在source选项卡下给所有闭包命名.

Screenshot

编辑完代码后,我们可以按CTRL+S保存并动态重新编译代码!

现在我们再录一段 堆分配快照 看看哪些闭包占用了内存.

很明显 SomeKindOfClojure () 是我们的恶棍. 现在我们可以看到 SomeKindOfClojure () 闭包被添加到一个名为 tasks 在全球空间中.

很容易看出这个数组是无用的. 我们可以把它注释掉. 但是我们如何释放已经被占用的内存呢? 很简单,我们只需要赋值一个空数组 tasks 下一个请求将被覆盖,内存将在下一个GC事件后被释放.

Screenshot

多比自由了!

V8中的垃圾寿命

好吧,V8 JS没有内存泄漏,只有忘记的变量.

好吧,V8 JS没有内存泄漏,只有忘记的变量.

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

  • New Space:此空间相对较小,大小在1MB到8MB之间. 大多数对象都在这里分配.
  • 旧指针空间:拥有可以指向其他对象的指针的对象. 如果对象在新空间中存活足够长的时间,它将被提升到旧指针空间.
  • 旧数据空间:只包含原始数据,如字符串,装箱数字和未装箱的双精度数组. 在新空间中通过GC存活足够长时间的对象也被移动到这里.
  • 大对象空间:在这个空间中创建的对象太大而无法放入其他空间. 每个对象都有自己的对象 mmap内存中的ed区域
  • Code space:包含由JIT编译器生成的汇编代码.
  • 单元格空间,属性单元格空间,映射空间:这个空间包含 Cells, PropertyCells, and Maps. 这是用来简化垃圾收集的.

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

V8有两种内置的垃圾收集机制:清除、标记-清除和标记-压缩.

清除是一种非常快速的垃圾收集技术,并对对象进行操作 New Space. 清道夫是实现的 切尼的算法. 这个想法很简单, New Space 被分成两个相等的半空间:To-Space和From-Space. 清理GC发生在To-Space已满时. 它只是交换To和From空间,并将所有活动对象复制到To- space,或者如果它们在两次清除中幸存下来,则将它们提升到一个旧空间, 然后从空间中完全抹去. 清除非常快,但是它们需要保持双倍大小的堆并不断地在内存中复制对象. 使用清除的原因是大多数对象都在年轻时就死亡了.

Mark-Sweep & Mark-Compact是V8中使用的另一种类型的垃圾收集器. 另一个名称是全垃圾收集器. 它标记所有活动节点,然后清除所有死节点并整理内存碎片.

GC性能和调试提示

而对于web应用程序来说,高性能可能不是一个大问题, 您仍将不惜一切代价避免泄漏. 在完全GC的标记阶段,应用程序实际上暂停,直到垃圾收集完成. 这意味着堆中的对象越多, 执行GC所需的时间越长,用户等待的时间也就越长.

总是给闭包和函数命名

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

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

避免在热函数中使用大型对象

理想情况下,您希望避免在热函数中使用大型对象,以便能够容纳所有数据 New Space. 所有CPU和内存受限的操作都应该在后台执行. 还要避免热函数的反优化触发器, 优化后的热函数比未优化的热函数使用更少的内存.

优化热功能

运行更快但消耗更少内存的热函数会导致GC的运行频率降低. V8提供了一些有用的调试工具来发现未优化的函数或未优化的函数.

在热函数中避免IC的多态性

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

函数x(a, b) {
  return a + b;
}

x(1, 2); // monomorphic
x(1, “string”); // polymorphic, level 2
x(3.14, 1); // polymorphic, level 3

When x(a,b) 第一次运行时,V8会创建一个单态IC. 当你打来电话 x 第二次, V8删除了旧的IC,并创建了一个新的多态IC,它支持整数和字符串两种类型的操作数. 当你第三次打电话给我的时候, V8重复相同的过程并创建另一个级别3的多态IC.

然而,这是有限制的. 当IC等级达到5级后(可更改为 -max_inlining_levels 标志)函数变成巨态,不再被认为是可优化的.

单态函数运行速度最快,占用的内存也更小,这是可以直观理解的.

不要向内存中添加大文件

这一点是显而易见且众所周知的. 如果你有大文件要处理, 例如一个大的CSV文件, 逐行读取并以小块处理,而不是将整个文件加载到内存中. 在相当罕见的情况下,单行csv会大于1mb, 这样你就可以把它放进去了 New Space.

不阻塞主服务器线程

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

不要创建不必要的数据

我曾经有过一段奇怪的restify经历. 如果您向无效的URL发送数十万个请求,那么应用程序内存将迅速增长到数百兆字节,直到几秒钟后启动完整的GC, 什么时候一切都会恢复正常. 结果是,对于每个无效URL, Restify生成一个新的错误对象,其中包括长堆栈跟踪. 这强制将新创建的对象分配到 大对象空间 而不是在 New Space.

在开发过程中获得这些数据将非常有帮助, 但在生产中显然不需要. 因此,规则很简单——除非确实需要,否则不要生成数据.

了解你的工具

最后,但肯定不是最不重要的,是了解你的工具. 有各种各样的调试器、泄漏检测器和使用图生成器. 所有这些工具都可以帮助您使软件更快、更高效.

Conclusion

理解V8的垃圾收集和代码优化器是如何工作的是提高应用程序性能的关键. V8将JavaScript编译为本机汇编,在某些情况下,编写良好的代码可以达到与GCC编译的应用程序相当的性能.

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

Joyent最近发布了Node的新版本.使用V8的最新版本之一. 一些为Node编写的应用程序.js v0.12.X可能与新的v4不兼容.x release. However, 应用程序将在新版本的Node中体验到巨大的性能和内存使用改进.js.

Toptal Engineering博客的进一步阅读:

聘请Toptal这方面的专家.
Hire Now

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

Toptal开发者

加入总冠军® community.