如果你想以最简单的方式提升 Node.js 程序的性能,那就升级到 node@8+ 吧。这不是一个玩笑,多少 JavaScript 前辈们以血的教训总结出了一长列 “Optimization killers”,典型的有:

  1. 在 try 里面不要写过多代码,V8 无法优化,最好将这些代码放到一个函数里,然后 try 这个函数。
  2. 少用 delete。
  3. 少用 arguments。

然而,随着 V8 彻底换上了新的 JIT 编译器—— Turbofan,大多数 “Optimization killers” 都已经成了过去时。所以在本节中我们来看看哪些过去常见的 “Optimization killers” 已经可以被 V8 优化。

3.4.1 Turbofan + Ignition

之前 V8 使用的是名为 Crankshaft 的编译器,这个编译器后来逐渐暴露出一些缺点:

  1. Doesn’t scale to full, modern JavaScript (try-catch, for-of, generators, async/await, …)
  2. Defaults to deoptimization (performance cliffs, deoptimization loops)
  3. Graph construction, inlining and optimization all mixed up
  4. Tight coupling to fullcodegen / brittle environment tracking
  5. Limited optimization potential / limited static analysis (i.e. type propagation)
  6. High porting overhead
  7. Mixed low-level and high-level semantics of instructions

而引入 Turbofan 的好处是:

  1. Full ESnext language support (try-catch/-finally, class literals, eval, generators, async functions, modules, destructuring, etc.)
  2. Utilize and propagate (static) type information
  3. Separate graph building from optimization / inlining
  4. No deoptimization loops / deoptimization only when really beneficial
  5. Sane environment tracking (also for lazy deoptimization)
  6. Predictable peak performance

Ignition 是 V8 新引入的解释器,用来将代码编译成简洁的字节码,而不是之前的机器码,这大大减少了结果代码,减少了系统的内存使用。由于字节码较小,所以可以编译全部源代码,而不用避免编译未使用的代码。也就是说,脚本只需要解析一次,而不是像之前的编译过程那样解析多次。

补充一点,之前的 V8 将代码编译成机器码执行,而新的 V8 将代码编译成字节码解释执行,动机是什么呢?可能是:

  1. 减少机器码占用的内存空间,即牺牲时间换空间(主要动机)。
  2. 加快代码的启动速度。
  3. 对 V8 的代码进行重构,降低 V8 的代码复杂度。

3.4.2 版本对应关系

  1. node@6 -> V8@5.1 -> Crankshaft
  2. node@8.0-8.2 -> V8@5.8 -> Crankshaft + Turbofan
  3. V8@5.9 -> Turbofan
  4. node@8.3-8.4 -> V8@6.0 -> Turbofan

3.4.3 try/catch

最著名的去优化之一是使用 try/catch 代码块。下面通过 4 种场景比较在不同的 V8 版本下执行的效率:

  1. var benchmark = require('benchmark')
  2. var suite = new benchmark.Suite()
  3. function sum (base, max) {
  4. var total = 0
  5. for (var i = base; i < max; i++) {
  6. total += i
  7. }
  8. }
  9. suite.add('sum with try catch', function sumTryCatch () {
  10. try {
  11. var base = 0
  12. var max = 65535
  13. var total = 0
  14. for (var i = base; i < max; i++) {
  15. total += i
  16. }
  17. } catch (err) {
  18. console.log(err.message)
  19. }
  20. })
  21. suite.add('sum without try catch', function noTryCatch () {
  22. var base = 0
  23. var max = 65535
  24. var total = 0
  25. for (var i = base; i < max; i++) {
  26. total += i
  27. }
  28. })
  29. suite.add('sum wrapped', function wrapped () {
  30. var base = 0
  31. var max = 65535
  32. try {
  33. sum(base, max)
  34. } catch (err) {
  35. console.log(err.message)
  36. }
  37. })
  38. suite.add('sum function', function func () {
  39. var base = 0
  40. var max = 65535
  41. sum(base, max)
  42. })
  43. suite.on('complete', require('./print'))
  44. suite.run()

运行结果如下:

Node@8 - 图1

结论:在 node@8.3 及以上版本中,在 try 块内写代码的性能损耗可以忽略不计。

3.4.4 delete

多年以来,delete 对于任何希望编写高性能 JavaScript 的人来说都是受限制的,我们通常用赋值 undefined 替代。delete 的问题归结为 V8 处理 JavaScript 对象的动态特性和原型链方式,使得属性查找在实现上变得复杂。下面通过 3 种场景比较在不同的 V8 版本下执行的效率:

  1. var benchmark = require('benchmark')
  2. var suite = new benchmark.Suite()
  3. function MyClass (x, y) {
  4. this.x = x
  5. this.y = y
  6. }
  7. function MyClassLast (x, y) {
  8. this.y = y
  9. this.x = x
  10. }
  11. suite.add('setting to undefined', function undefProp () {
  12. var obj = new MyClass(2, 3)
  13. obj.x = undefined
  14. JSON.stringify(obj)
  15. })
  16. suite.add('delete', function deleteProp () {
  17. var obj = new MyClass(2, 3)
  18. delete obj.x
  19. JSON.stringify(obj)
  20. })
  21. suite.add('delete last property', function deleteProp () {
  22. var obj = new MyClassLast(2, 3)
  23. delete obj.x
  24. JSON.stringify(obj)
  25. })
  26. suite.add('setting to undefined literal', function undefPropLit () {
  27. var obj = { x: 2, y: 3 }
  28. obj.x = undefined
  29. JSON.stringify(obj)
  30. })
  31. suite.add('delete property literal', function deletePropLit () {
  32. var obj = { x: 2, y: 3 }
  33. delete obj.x
  34. JSON.stringify(obj)
  35. })
  36. suite.add('delete last property literal', function deletePropLit () {
  37. var obj = { y: 3, x: 2 }
  38. delete obj.x
  39. JSON.stringify(obj)
  40. })
  41. suite.on('complete', require('./print'))
  42. suite.run()

运行结果如下:

Node@8 - 图2

结论:在 node@8 及以上版本中,delete 一个对象上的属性比 node@6 快了一倍。在 node@8.3 及以上版本中,delete 一个对象上最后一个属性几乎与赋值 undefined 同样快了。

3.4.5 arguments

我们知道 arguments 是个类数组,所以通常我们要使用 Array.prototype.slice.call(arguments) 将它转化成数组再使用,这样会有一定的性能损耗。下面通过 4 种场景比较在不同的 V8 版本下执行的效率:

  1. var benchmark = require('benchmark')
  2. var suite = new benchmark.Suite()
  3. function leakyArguments () {
  4. return other(arguments)
  5. }
  6. function copyArgs () {
  7. var array = new Array(arguments.length)
  8. for (var i = 0; i < array.length; i++) {
  9. array[i] = arguments[i]
  10. }
  11. return other(array)
  12. }
  13. function sliceArguments () {
  14. var array = Array.prototype.slice.apply(arguments)
  15. return other(array)
  16. }
  17. function spreadOp(...args) {
  18. return other(args)
  19. }
  20. function other (toSum) {
  21. var total = 0
  22. for (var i = 0; i < toSum.length; i++) {
  23. total += toSum[i]
  24. }
  25. return total
  26. }
  27. suite.add('leaky arguments', () => {
  28. leakyArguments(1, 2, 3)
  29. })
  30. suite.add('Array.prototype.slice arguments', () => {
  31. sliceArguments(1, 2, 3)
  32. })
  33. suite.add('for-loop copy arguments', () => {
  34. copyArgs(1, 2, 3)
  35. })
  36. suite.add('spread operator', () => {
  37. spreadOp(1, 2, 3)
  38. })
  39. suite.on('complete', require('./print'))
  40. suite.run()

运行结果如下:

Node@8 - 图3

结论:在 node@8.3 及以上版本中,使用对象展开运算符是除直接使用 arguments 外最快的方案,对于 node@8.2 及以下的版本,我们应该使用一个 for 循环将 key 从 arguments 复制到一个新的(预先分配的)数组中。总之,是时候抛弃 Array.prototype.slice.call 了。

3.4.6 async 性能提升

V8@5.7 发布后,原生的 async 函数与 Promise 一样快了,同时,Promise 的性能也比 V8@5.6 快了一倍。如图所示:

Node@8 - 图4

3.4.7 不会优化的特性

并不是说上了 Turbofan 就能优化所有的 JavaScript 语法,有些语法 V8 是不会去优化的(也没有必要),例如:

  1. debugger
  2. eval
  3. with

我们以 debugger 为例,比较使用和不使用 debugger 时的性能:

  1. var benchmark = require('benchmark')
  2. var suite = new benchmark.Suite()
  3. suite.add('with debugger', function withDebugger () {
  4. var base = 0
  5. var max = 65535
  6. var total = 0
  7. for (var i = base; i < max; i++) {
  8. debugger
  9. total += i
  10. }
  11. })
  12. suite.add('without debugger', function withoutDebugger () {
  13. var base = 0
  14. var max = 65535
  15. var total = 0
  16. for (var i = base; i < max; i++) {
  17. total += i
  18. }
  19. })
  20. suite.on('complete', require('./print'))
  21. suite.run()

运行结果如下:

Node@8 - 图5

结论:在所有测试的 V8 版本中,debugger 一直都很慢,所以记得在打断点测试完后一定要删掉 debugger。

3.4.8 总结

  1. 使用最新 LTS 版本的 Node.js。
  2. 关注 V8 团队的博客——https://v8project.blogspot.com,了解第一手资讯。
  3. 清晰的代码远比使用一些奇技淫巧提升的一点性能重要得多。

3.4.9 参考链接

上一节:3.3 Error Stack

下一节:3.5 Rust Addons