本文要点
- 借助分组或批量写,尽量最小化系统调用数量。
- 考虑应用中各种定时器的发布和清除开销。
- CPU 性能分析器能提供有用的信息,但是不会告诉问题的原委。
- 慎用 ECMAScript 高级特性,尤其是在未使用最新版 JavaScript 引擎或源码到源码的编译器时。
- 控制 QDF 依赖树,并对依赖做基准测试。
为了改进涉及 IO 操作的 Node.js 应用的性能,你应了解 CPU 周期的使用情况,更为重要的是知道妨碍应用高度并行的症结所在。
我在关注改进 Apache Cassandra 的 DataStax Node.js 驱动的整体性能时,对此问题有了一些洞悉,并以此文分享出来,力图总结可导致应用吞吐量降级的最为重要的症结。
背景知识
Node.js 使用的 JavaScript 引擎 V8 将 JavaScript 编译成机器码,并以原生代码运行。为尽量达到低启动时间和峰值性能,V8 引擎使用了三个组件:
- 通用编译器,尽可能地快速地将 JavaScript 编译为机器码。
- 运行时性能分析器,追踪各部分代码运行所耗费的时间,识别其中值得优化的代码。
- 优化编译器,尽量优化被性能分析器识别的代码。它支持对优化器所做的过于乐观的假设去优化(deopt)。
通常优化编译器能达到最好的性能,但是并未选取全部 JavaScript 代码做优化,即存在被优化编译器拒绝优化的代码模式。
对于那些不能被 V8 优化但是或许有变通方案的代码模式,你可以使用来自于 Google Chrome DevTools 团队的解决方案作为工作指南找出他们。下面列出部分例子:
- 具有 try-catch 语句的函数。
- 在使用
arguments
域时,重赋值参数。
尽管优化编译器显著地加快了代码运行,但是正如我们将在下文中的,为在 IO 密集的应用中每秒能完成更多操作,大多数性能改进解决方法关注的是如何重排序指令以及使用更低代价的调用。
基准测试
为找到那些影响用户数量最大的可优化部分,重要的是对基准测试的定义。基准测试使用的工作负荷具有常用的执行路径,模拟了真实世界中的使用情况。
基准测试首先测定 API 入口点的吞吐量和延迟。你也可对单独内部方法的性能做基准测试,以得到更为详细的信息。使用process.hrtime()
可实时地获取高精度的时间信息,得到程序执行的时间长度。
你应尽量创建有限但切实可行的基准测试。从方法吞吐量测定这样的小问题开始,然后添加更多更全面的信息,例如延迟分布等。
CPU 性能分析
现有多种 CPU 性能分析工具,Node.js 也提供了一种开箱可用的工具,在很多情况下该工具足以适用。内建的Node.js 性能分析工具使用 V8 引擎内的性能分析器,在程序执行中对程序栈做周期性地采样。使用–prof 标志运行 node 将会生成 V8 的时钟周期文件。
然后你可以对性能分析会话的输出进行处理,聚合输出信息,并使用–prof-process 标识将输出信息转换为用户可读的内容:
$ node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt
可使用文本编辑器打开处理后的文本文件,其中的信息是以章节分隔。
在文件中查找“Summary”一章,内容类似于:
[Summary]: ticks total nonlib name 20109 41.2% 45.7% JavaScript 23548 48.3% 53.5% C++ 805 1.7% 1.8% GC 4774 9.8% Shared libraries 356 0.7% Unaccounted
其中各项值表示了 JavaScript/C++ 代码 / 垃圾收集器中进行的采样次数,根据被分析代码的各种类型而有所不同。查看文件中类型所对应的子章节(例如:[JavaScript]、[C++] 等),可得到按发生频次排序的采样细节。
在处理后的性能分析输出文件中,名为“[Bottom up (heavy) profile]”的部分尤其有用。该部分给出了每个函数的主要调用者的相关信息,以类似于树的结构显示。以下面的代码段为例:
223 32% LazyCompile: *function1 lib/file1.js:223:20 221 99% LazyCompile: ~function2 lib/file2.js:70:57 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
感谢王纯超对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们。
评论