[关闭]
@Rays 2017-02-07T13:02:52.000000Z 字数 4668 阅读 2019

一次一个微优化,改进Node.js应用的吞吐量

Node.js


摘要: 为提升涉及IO操作的Node.js应用的性能,应了解CPU周期的使用情况,更为重要的是知道妨碍应用高度并行的症结所在。本文作者分享了他对该问题的认识,分析了导致吞吐量下降的原因,并给出了一些提升Node.js应用性能的技巧。

作者: Jorge Bay

正文:

本文要点

  • 借助分组或批量写,尽量最小化系统调用数量。
  • 考虑应用中各种定时器的发布和清除开销。
  • CPU性能分析器能提供有用的信息,但是不会告诉问题的原委。
  • 慎用ECMAScript高级特性,尤其是在未使用最新版JavaScript引擎或源码到源码的编译器时。
  • 控制依赖树,并对依赖做基准测试。

为了改进涉及IO操作的Node.js应用的性能,你应了解CPU周期的使用情况,更为重要的是知道妨碍应用高度并行的症结所在。

我在关注改进Apache Cassandra的DataStax Node.js驱动的整体性能时,对此问题有了一些洞悉,并以此文分享出来,力图总结可导致应用吞吐量降级的最为重要的症结。

背景知识

Node.js使用的JavaScript引擎V8将JavaScript编译成机器码,并以原生代码运行。为尽量达到低启动时间和峰值性能,V8引擎使用了三个组件:

  1. 通用编译器,尽可能地快速地将JavaScript编译为机器码。
  2. 运行时性能分析器,追踪各部分代码运行所耗费的时间,识别其中值得优化的代码。
  3. 优化编译器,尽量优化被性能分析器识别的代码。它支持对优化器所做的过于乐观的假设去优化(deopt)。

通常优化编译器能达到最好的性能,但是并未选取全部JavaScript代码做优化,即存在被优化编译器拒绝优化的代码模式。

对于那些不能被V8优化但是或许有变通方案的代码模式,你可以使用来自于Google Chrome DevTools团队的解决方案作为工作指南找出他们。下面列出部分例子:

尽管优化编译器显著地加快了代码运行,但是正如我们将在下文中的,为在IO密集的应用中每秒能完成更多操作,大多数性能改进解决方法关注的是如何重排序指令以及使用更低代价的调用。

基准测试

为找到那些影响用户数量最大的可优化部分,重要的是对基准测试的定义。基准测试使用的工作负荷具有常用的执行路径,模拟了真实世界中的使用情况。

基准测试首先测定API入口点的吞吐量和延迟。你也可对单独内部方法的性能做基准测试,以得到更为详细的信息。使用process.hrtime()可实时地获取高精度的时间信息,得到程序执行的时间长度。

你应尽量创建有限但切实可行的基准测试。从方法吞吐量测定这样的小问题开始,然后添加更多更全面的信息,例如延迟分布等。

CPU性能分析

现有多种CPU性能分析工具,Node.js也提供了一种开箱可用的工具,在很多情况下该工具足以适用。内建的Node.js性能分析工具使用V8引擎内的性能分析器,在程序执行中对程序栈做周期性地采样。使用--prof标志运行node将会生成V8的时钟周期文件。

然后你可以对性能分析会话的输出进行处理,聚合输出信息,并使用--prof-process标识将输出信息转换为用户可读的内容:

  1. $ node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt

可使用文本编辑器打开处理后的文本文件,其中的信息是以章节分隔。

在文件中查找“Summary”一章,内容类似于:

  1. [Summary]:
  2. ticks total nonlib name
  3. 20109 41.2% 45.7% JavaScript
  4. 23548 48.3% 53.5% C++
  5. 805 1.7% 1.8% GC
  6. 4774 9.8% Shared libraries
  7. 356 0.7% Unaccounted

其中各项值表示了JavaScript/C++代码/垃圾收集器中进行的采样次数,根据被分析代码的各种类型而有所不同。查看文件中类型所对应的子章节(例如:[JavaScript]、[C++]等),可得到按发生频次排序的采样细节。

在处理后的性能分析输出文件中,名为“[Bottom up (heavy) profile]”的部分尤其有用。该部分给出了每个函数的主要调用者的相关信息,以类似于树的结构显示。以下面的代码段为例:

  1. 223 32% LazyCompile: *function1 lib/file1.js:223:20
  2. 221 99% LazyCompile: ~function2 lib/file2.js:70:57
  3. 221 100% LazyCompile: *function3 /lib/file3.js:58:74

每行中的百分比值显示了调用者占父调用总量的比例。函数名前面的星号表示函数优化后所用时间,波浪号表示未优化的函数。

在本例中,根据性能分析采样,99%的function1调用来自function2,而100%的function2调用来自function3。

对于了解大部分时间中堆栈中的内容以及消耗CPU时间的方法,CPU性能分析会话和结构框图是非常有用的工具,使用他们便于找到易于优化的目标。同时你应该明白他们并未提供全面的信息。例如,异步IO操作会提高应用中的并行,但同时也会使导致性能降低的问题难以识别。

系统调用

Node.js可使用libuv提供的独立于平台的API去执行非阻塞IO操作。Node.js应用的IO操作(socket、文件系统等)最终都将转化为系统调用。

这些系统调用的调度需付出相当高的代价。应尽量使用分组或批量写去最小化系统调用量。

在使用socket或文件系统时,不应每次发布一个写操作,而应随时缓存并清空数据。

你可以使用写队列去处理并分组写操作。写队列实现的逻辑应类似于:

窗口大小可以根据缓存的总长度定义,或是根据自第一个条目进入队列后所过去的时间。定义窗口大小是在单一写延迟和平均写延迟之间取得权衡。也应考虑需组织在一起的写请求的总量,以及产生每次写请求的代价。

你通常会按写入内容的大小顺序将内容写入到缓存中。我们发现8KB大小的缓存是合适的,但是你可能并不这么认为。你可以去查看我们在客户驱动中的实现,了解完整的写队列实现

降低系统调用量使得分组或批量写转化为更高的吞吐量。

Node.js定时器

Node.js定时器十分有用,它的API和Web API中的window对象的计时器的API一样,易于调度和去调度,并已广泛用于整个生态系统。

鉴于此,应用在任何时刻都可能会出现大量的超时调度。

类似于其他的哈希轮盘定时器(Hased Wheel Timer),Node.js 使用哈希表和链表维护定时器实例。但是不同于其他的轮盘定时器,Node.js并没有使用固定长度哈希表,而是以持续时间作为各个定时器列表的键值。

当列表中存在一个键值时(即存在持续时间相同的定时器),定时器以O(1)代价的操作附加到桶上。

当该键值在列表中不存在时,Node.js新创建一个桶,并将定时器附加到该桶上。

因此必须要确认已有的桶被重新使用,尽量避免移除整个桶并创建新桶。例如,如果你正在使用滑动延迟,应在移除旧的超时(cleartimeout())前就创建新的超时(setTimeout())。

对于我们而言,我们将调度空闲的超时(心跳)先于移除前期超时实现,这确保了O(1)代价的空闲超时调度和去调度操作。

Ecmascript特性

如果你关注的是性能问题,应该慎用一些Ecmascript高层特性,其中包括:Function.prototype.bind()Object.defineProperty()Object.defineProperties()等。

这些特性的性能不好,主要由JavaScript引擎中的实现细节所导致。其中的一些问题已得到解决,例如:在V8 5.3引擎中Promise性能的改进在V8 5.4引擎中Function.prototype.bind的性能

应对ES2015和ESNext中的新语言特性格外谨慎。与ECMAScript 5中的相应特性相比,这些新语言特性明显要慢。six-speed项目网站跟踪记录了他们在不同JavaScript引擎上性能的进度。此外,在不能从现有基准测试中找到结论性结果时,你可以对各种方法做微基准测试。

V8团队正在致力于改进新语言特性的性能,最终要达到与原生特性相同性能。他们通过一份性能规划协调针对ES2015及以后版本引入的特性的优化工作,V8团队通过该计划收集需要改进的地方以及提议的应对这些问题的设计文档。

你可以通过指定博客跟踪V8实现的进展,但是考虑到这些改进还需相当长的时间才能进入到Node.js的长期支持(LTS,Long-term Support)版本中(根据LTS规划,进入Node.js主版本的V8版本通常是在该版本被从主分支裁剪前确定),为使用包括新的V8主版本或小版本的Node.js运行时,你将不得不再等待6到12个月的时间。

新的Node.js主版本将只以补丁形式更新V8引擎

依赖

Node.js运行时提供了完整的IO操作函数库,但是由于ECMAScript规范中提供了非常少的内建类型,有时你不得不依赖于外部软件包去执行其他基本任务。

即便是那些广为使用的模块,也不能保证发布的软件包会以有效方式并正确地工作。Node.js的生态系统非常庞大,通常这些第三方模块只包括了很少能让你自己实现的方法。

对于去重做轮子还是去控制性能对依赖的影响,你应该在两者间权衡。

任何情况下都应避免添加新的依赖。不要相信你的依赖,就是这样。此原则的例外是如果所依赖的项目自身发布了可靠的基准测试,就像bluebird程序库那样。

就我们而言,async对请求延迟有影响。我们在代码块中广泛地使用了async.series()async.waterfall()async.whilst()。由于控制流程序库是一个横切关注点,这使得难以识别导致性能问题的坏份子。由于async是最为广泛使用的模块之一,由async导致的性能问题得到了广泛关注。async具有简化的替代实现,例如neo-async。neo-async的运行性能得到了显著的改进,也发布了基准测试。

总结

虽然这里给出的一些优化技术对于其它的技术也是通用的,但是其中的部分技术是特定于Node.js生态系统及JavaScript引擎和核心库的工作方式的。

对于我们的客户驱动,其中已经应用了这些优化技术。根据我们的基准测试结果,这些优化技术导致了吞吐量增加了两倍以上。

考虑到我们的代码在Node.js上是单线程运行的,优化程度还取决于应用消耗CPU周期的方式和指令的顺序。我们可以通过支持高度并行改进整体的吞吐量。

关于作者

Jorge Bay是DataStax公司负责Apache Cassandra和DSE的Node.js和C#客户驱动的首席工程师。他在本职工作之余,也享受解决问题和构建服务器端解决方案的乐趣。Jorge具有超过15年的专业软件开发经验,他实现了Apache Cassandra的社区版Node.js驱动,该驱动也是DataStax官方驱动的基础。

查看英文原文:Improve Your Node.js App Throughput One Micro-optimization at a Time

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注