这 3 个 JS 性能基础,让 Bluebird 更快速

正如我们在近期的文章《 Promises made by a Reaktor devloper had an impact on the industry 》中许诺的那样,以下是我们 Petka Antonov – 程序员和备受赞誉的 Bluebird promise 库的创造者,分享的一些原创知识。

Bluebird 是一个被广泛使用的 JS promise 库,最初被注意到是在 2013 年,因其实施速度比当时类似功能的其它 promise 库快了 100 倍。Bluebird 如此之快的原因在于,它对 JavaScript 优化的基础原理的运用贯穿了整个库。本文将详细介绍三种用于优化 Bluebird 的最有价值的基础知识。

1. 函数对象分配最小化

对象分配,尤其是函数对象分配,在实现时由于产生大量的内部数据,对性能造成沉重的负担。JavaScript 的实际实现是一种垃圾回收机制,所以分配的对象并不是简单地存储在内存中,垃圾回收器在不断地寻找未使用的对象,从而释放它们。在 JavaScript 中使用的内存越多,垃圾回收器占用的 CPU 资源也就越多,从而执行实际代码的 CPU 也就越少。

在 JavaScript 中,函数是第一类对象。这意味着它们和任何其它对象一样,具有相同的特征和属性。如果你有一个包含了另一个或多个函数的代码声明的函数,那么对父函数的每一次调用都会创建新的、唯一的函数对象,尽管执行了相同的代码。如下是一个基本的例子:

现在每次调用 trim 函数,都会创建两个不必要的函数对象来表示 trimStrat 和 trimEnd。函数对象是不必要的,因为它们并不用于对象的唯一标识,例如属性赋值或变量封装。他们只用于代码所包含的功能。

这个例子很容易优化,只需简单地把这些函数移出 trim 。由于示例包含在模块当中,并且模块仅为程序加载一次,所以函数将只存在一种表现形式:

然而,更多常见的函数对象似乎是一个无法避免的弊病,没办法这么轻易地优化。例如,任何时候你传递一个稍后会被调用的回调函数,几乎总是需要一个特定的上下文来进行回调。通常情况下,这种上下文以简单而直观但低效的闭包方式来实现。一个简单的例子就是使用默认的异步回调接口在节点中读取一个 JSON 文件:

在这个例子中,传递给 fs.readFile 的回调函数不能被移出 readFileAsJson因为它通过唯一的变量回调创建了一个闭包。还应该注意的是,fs.readFile 无论是作为命名函数声明还是内联匿名函数来都没什么区别。

很大程度上 Bluebird 内部使用的优化是使用显示的简单对象来保存上下文数据。由一个通过多层的回调组成的操作,只需要一次这样的对象分配。每次将回调传递到另一个层时,每个层将不会创建一个新的闭包,而是将显式的简单对象作为一个额外的参数进行传递。举个例子,假设一个操作中有五个回调的步骤,使用闭包意味着分配五个函数对象和上下文对象,但是如果使用这个plain object方法来优化的话,总共只分配一个 plain object 就可以了。

我们可以修改一下 fs.readFile API让它接受一个上下文对象,在刚刚的例子中使用这种优化方法以后,代码看起来就是这样的:

很明显,你需要同时控制 API 的两端,这使得这种优化方法对于不接受上下文对象参数的 API 不可行。然而,当你可以使用这种方法的时候(比如当你控制多个内部层的时候),这会带来非常大的性能提升。还有一个已知的事实:像 Array.prototype.forEach 这样的一些内建的 JavaScript 数组 API 可以接受一个上下文对象作为第二个参数。

2. 对象大小最小化

最小化那些经常并大量分配的对象(如 promises)的大小是至关重要的。在最常用的 JavaScript 实现中,对象分配的堆被分成不同的段和空间。较小的对象比较大的对象需要更多的时间来填满这些空间和段,从而减少了垃圾回收器的工作量。在决定对象有效或失效时,较小的对象可以使垃圾回收器访问更少的字段。

布尔和/或受限制的整数字段时,可以通过位运算符打包到一个更小的空间。JavaScript 位运算符可以操作 32 位的整数,因此你可以将 32 个布尔字段或 8 个 4 位的整数或 16 个布尔值和 2 个 8 位的整数等合并到一个字段中。为了保持代码的可读性,每一个逻辑字段都应该有一对 getter 和 setter 函数来执行物理字段的正确的位操作。将一个布尔字段打包为一个整数(未来可以扩大到容纳更多的逻辑字段)的例子如下:

这个存取方法非常短,因此它们在运行时很有可能被内联,从而不涉及函数调用的开销。

当使用一个布尔值来追踪这个字段保存的是哪种类型的值的时候,两个或多个从来不会同时使用的字段就可以压缩为一个字段。然而,像之前描述的办法一样,将这个布尔字段作为一个打包好的整数字段来执行仅仅是节约了一些空间而已。

在 Bulebird 中,这个技巧被用来储存 promise 执行的返回值或拒绝原因。这里并没有明显的分界:如果 promise 执行成功,那么这个返回值可能会被存储在失败的回调函数的区域;请求失败时,拒绝请求的原因也可能存储在成功的回调函数区域。再重复一次,所有的访问都应该通过存取函数来隐藏这些难看的优化细节。

如果一个对象请求了一系列值,你可以通过将这些值直接储存在对象的索引中,从而避免一个单独的数组分配。

所以,不要这样:

你可以避免使用数组:

如果 .length 可以被限制到一个很小的整数(比如 10 位,这将会把事件发射器限制为最多 1024 个监听器),那么它就可以和其他的受限制的整数或布尔值一起压缩成为一个打包好的字段。

3. 使用空函数并简单地覆盖它们来实现代价昂贵的可选功能

Bluebird 有一些可选的功能特性在使用时会导致整个库的统一的性能损失。这些功能是:警告,长的栈追踪,取消操作,Promise.prototype.bind 和 promise 状态监控。这些功能会在库中调用钩子函数(hook functions)。举个例子,每当创建一个 promise 对象的时候,promise 监控功能都要被调用一次。

比起不管监控功能是否启用都调用钩子函数,在调用前先检查一下监控功能是否启用要好得多。然而,由于内置缓存和函数内联,对于不启用该功能的用户,实际上可以完全消除成本。这可以通过将原始的 hook 方法设置为空操作函数来实现:

现在,如果用户没有启用监控功能,优化器就会发现这个函数调用不执行任何操作,并将其消除。所以对 constructor 中 hook 方法的有效调用并不存在。

为了使这个功能真正地执行,启用该功能时必须用真正的实现覆盖所有相关的空操作函数:

像这样的覆盖方法将会使所有 Promise 类的对象建立的内置缓存失效,因此只应该在启动应用程序时,在所有 promise 对象创建以前执行。如果在任一内存使用之前发生了覆盖,那么在功能被启用后的操作创建的内置缓存也不会识别到这些空操作函数的存在。

打赏支持我翻译更多好文章,谢谢!

打赏译者

打赏支持我翻译更多好文章,谢谢!

1 收藏 评论

关于作者:刘唱

数据挖掘研究生 个人主页 · 我的文章 · 33 ·    

可能感兴趣的话题



直接登录
跳到底部
返回顶部