作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Vlad是一位多才多艺的软件工程师,在许多领域都有经验. 他目前正在完善自己的Scala和机器学习技能.
我曾经开过一辆装有V8双涡轮增压发动机的奥迪,它的性能令人难以置信. 凌晨3点,我在芝加哥附近的IL-80高速公路上以140英里每小时的速度行驶,路上一个人也没有. 从那时起,“V8”这个词对我来说就和高性能联系在一起了.
虽然奥迪的V8引擎非常强大,但你的油箱容量仍然有限. 谷歌的V8也是如此——Node背后的JavaScript引擎.js. 它的性能令人难以置信,有很多原因.js 适用于许多用例,但是你总是受到堆大小的限制. 当您需要在Node中处理更多请求时.在Js应用程序中,你有两种选择:垂直扩展或水平扩展. 水平扩展意味着您必须运行更多的并发应用程序实例. 如果处理得当,您最终能够服务更多请求. 垂直扩展意味着您必须改进应用程序的内存使用和性能,或者增加应用程序实例可用的资源.
最近我被要求开发一个Node.为我的一个Toptal客户修复内存泄漏问题. 应用程序, API服务器, 打算每分钟处理数十万个请求. 最初的应用程序占用了近600MB的RAM,因此我们决定采用热门API端点并重新实现它们. 当您需要处理许多请求时,开销会变得非常昂贵.
对于新的API,我们选择了使用本地MongoDB驱动程序的restify和用于后台任务的Kue. 听起来很轻巧,对吧? Not quite. 在峰值负载期间,一个新的应用程序实例可能会消耗高达270MB的RAM. 因此,我希望每1X Heroku Dyno拥有两个应用程序实例的梦想消失了.
如果您搜索“如何查找节点中的泄漏”,您可能会找到的第一个工具是 memwatch. 原来的包很久以前就被抛弃了,不再维护. 但是你可以很容易地在GitHub上找到它的新版本 存储库的Fork列表. 这个模块很有用,因为如果它看到堆增长超过5个连续的垃圾收集,它可以发出泄漏事件.
很棒的工具 Node.js开发人员 以获取堆快照,并在稍后使用Chrome开发工具检查它们.
这是一个比堆转储更有用的选择, 因为它允许您连接到正在运行的应用程序, 获取堆转储,甚至动态地调试和重新编译它.
不幸的是, 您将无法连接到在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个并发用户.
Ab -c 50 -n 1000000 -k http://example.com/
在创建新快照之前, V8将执行标记-清除垃圾收集, 所以我们肯定知道快照中没有旧的垃圾.
在收集一段时间内的堆分配快照后 3 minutes 我们最终得到如下结果:
我们可以清楚地看到有一些巨大的阵列, 很多IncomingMessage, ReadableState, ServerResponse和Domain对象以及堆中的对象. 让我们试着分析一下泄漏的源头.
在图表上选择堆差从20到40, 我们只会看到从你启动分析器开始的20秒后添加的对象. 这样就可以排除所有正常数据.
记录系统中每种类型的对象的数量, 我们将过滤时间从20s扩展到1min. 我们可以看到,已经相当巨大的阵列还在不断增长. 在“(array)”下,我们可以看到有很多相等距离的对象“(对象属性)”. 这些对象是内存泄漏的根源.
我们还可以看到“(闭包)”对象也在快速增长.
看一下字符串也会很方便. 在弦乐列表下面有很多“Hi Leaky Master”的短语. 这些可能也会给我们一些线索.
在我们的例子中,我们知道字符串“Hi Leaky Master”只能在“GET /”路由下组装.
如果你打开retainers路径,你会看到这个字符串以某种方式通过 req,然后创建上下文,并将所有这些添加到一些巨大的闭包数组中.
现在我们知道我们有一个庞大的闭包数组. 让我们在source选项卡下给所有闭包命名.
编辑完代码后,我们可以按CTRL+S保存并动态重新编译代码!
现在我们再录一段 堆分配快照 看看哪些闭包占用了内存.
很明显 SomeKindOfClojure () 是我们的恶棍. 现在我们可以看到 SomeKindOfClojure () 闭包被添加到一个名为 tasks 在全球空间中.
很容易看出这个数组是无用的. 我们可以把它注释掉. 但是我们如何释放已经被占用的内存呢? 很简单,我们只需要赋值一个空数组 tasks 下一个请求将被覆盖,内存将在下一个GC事件后被释放.
多比自由了!
V8的堆被分成几个不同的空间:
mmap
内存中的ed区域Cell
s, PropertyCell
s, and Map
s. 这是用来简化垃圾收集的.每个空间由页面组成. 页是使用mmap从操作系统分配的内存区域. 除了大对象空间中的页面外,每个页面的大小始终为1MB.
V8有两种内置的垃圾收集机制:清除、标记-清除和标记-压缩.
清除是一种非常快速的垃圾收集技术,并对对象进行操作 New Space. 清道夫是实现的 切尼的算法. 这个想法很简单, New Space 被分成两个相等的半空间:To-Space和From-Space. 清理GC发生在To-Space已满时. 它只是交换To和From空间,并将所有活动对象复制到To- space,或者如果它们在两次清除中幸存下来,则将它们提升到一个旧空间, 然后从空间中完全抹去. 清除非常快,但是它们需要保持双倍大小的堆并不断地在内存中复制对象. 使用清除的原因是大多数对象都在年轻时就死亡了.
Mark-Sweep & Mark-Compact是V8中使用的另一种类型的垃圾收集器. 另一个名称是全垃圾收集器. 它标记所有活动节点,然后清除所有死节点并整理内存碎片.
而对于web应用程序来说,高性能可能不是一个大问题, 您仍将不惜一切代价避免泄漏. 在完全GC的标记阶段,应用程序实际上暂停,直到垃圾收集完成. 这意味着堆中的对象越多, 执行GC所需的时间越长,用户等待的时间也就越长.
当所有闭包和函数都有名称时,检查堆栈跟踪和堆要容易得多.
db.查询('GIVE THEM ALL', function GiveThemAllAName(error, data) {
...
})
理想情况下,您希望避免在热函数中使用大型对象,以便能够容纳所有数据 New Space. 所有CPU和内存受限的操作都应该在后台执行. 还要避免热函数的反优化触发器, 优化后的热函数比未优化的热函数使用更少的内存.
运行更快但消耗更少内存的热函数会导致GC的运行频率降低. V8提供了一些有用的调试工具来发现未优化的函数或未优化的函数.
内联缓存(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.
在开发过程中获得这些数据将非常有帮助, 但在生产中显然不需要. 因此,规则很简单——除非确实需要,否则不要生成数据.
最后,但肯定不是最不重要的,是了解你的工具. 有各种各样的调试器、泄漏检测器和使用图生成器. 所有这些工具都可以帮助您使软件更快、更高效.
理解V8的垃圾收集和代码优化器是如何工作的是提高应用程序性能的关键. V8将JavaScript编译为本机汇编,在某些情况下,编写良好的代码可以达到与GCC编译的应用程序相当的性能.
如果你想知道的话, Toptal客户端的新API应用程序, 尽管还有改进的余地, 效果很好!
Joyent最近发布了Node的新版本.使用V8的最新版本之一. 一些为Node编写的应用程序.js v0.12.X可能与新的v4不兼容.x release. However, 应用程序将在新版本的Node中体验到巨大的性能和内存使用改进.js.
世界级的文章,每周发一次.
世界级的文章,每周发一次.