Bluebird 高性能揭秘
MalloryU29
8年前
<p>Bluebird 是一个广泛使用的 Promise 库,最早在 2013 年得到人们的关注。相比其他同等水平的 Promise 库,Bluebird 快了一百来倍。Bluebird 自始至终遵循着 JavaScript 优化的一些基本原则,所以才有这么好的性能。本文将会介绍其中最有价值的三个方面。</p> <h3>1. 函数中的对象分配最小化</h3> <p>对象分配(object allocation),尤其是函数中的对象分配,对性能的影响是很大的,因为其实现需要用到大量内部数据。JavaScript 实现了垃圾自动回收,占用内存的不单是分配的对象;垃圾回收器也有份,它在不断寻找那些不再使用的对象,以释放内存。JavaScript 占用内存越多,垃圾回收需要的 CPU 资源也就越多,这样一来,运行代码的 CPU 资源就会减少。</p> <p>函数是 JavaScript 中的一等对象,和其他对象有着相同的特性。假设在函数 fnA 中,声明了另一个函数 fnB,那么每次调用外层的 fnA 时,都会有一个全新的 fnB 函数对象被创建,哪怕两次代码完全一样。请看下面的例子:</p> <pre> <code class="language-javascript">function trim(string) { function trimStart(string) { return string.replace(/^\s+/g, ""); } function trimEnd(string) { return string.replace(/\s+$/g, ""); } return trimEnd(trimStart(string)) }</code></pre> <p>每次调用 trim 函数的时候,两个并非必需的函数对象(trimStart 和 trimEnd 函数)就会被创建出来。说这两个函数对象并非必需,是因为它们作为独特对象的特点并未起到丝毫作用,如属性赋值、变量隐藏等,所用到的仅仅是它们的内部功能而已。</p> <p>要优化这个例子并不麻烦,将那两个函数移到 trim 函数之外就好。它们同处于相同模块,只会加载一次,所以这两个函数各自只会创建一个函数对象:</p> <pre> <code class="language-javascript">function trimStart(string) { return string.replace(/^\s+/g, ""); } function trimEnd(string) { return string.replace(/\s+$/g, ""); } function trim(string) { return trimEnd(trimStart(string)) }</code></pre> <p>但更为常见的情况是,函数对象似乎是一种必要之恶,优化并不像上面这般简单。比如说,传递回调函数时,总是需要考虑特定上下文。这通常可以用闭包实现,简单又直观,效率却极低。举个小例子,使用 Node 读取 JSON 文件:</p> <pre> <code class="language-javascript">var fs = require('fs'); function readFileAsJson(fileName, callback) { fs.readFile(fileName, 'utf8', function(error, result) { // 每次调用 readFileAsJson 函数时,会创建一个新的函数对象 // 因为是闭包,也会分配一个内部上下文对象来保存状态 if (error) { return callback(error); } // 需要 try-catch 来处理可能存在的非法 JSON 造成的语法错误 try { var json = JSON.parse(result); callback(null, json); } catch (e) { callback(e); } }) }</code></pre> <p>在上面的例子中,传给 fs.readFile 的匿名回调,是不能从 readFileAsJson 函数中提取出来的,因为该匿名函数能够访问其外部的 callback 变量。需要注意的是,即便使用命名函数取代匿名函数,也不会有任何区别。</p> <p>Bluebird 内部常用到的优化方法,是采用明确的普通对象保存与上下文相关的数据。对一次包含逐层传递 callback 的操作来说,只需分配一次对象。相比每当 callback 传入另一层函数时就需要创建新闭包,优化方法只需要传递一个额外的参数。假设某个操作调用 callback 分五步进行,若使用闭包则意味着要分配五个函数对象外加五个上下文对象,而使用优化方法则只需要一个普通对象。</p> <p>假如可以修改 fs.readFile API,使其接收一个上下文对象,那么前面的例子可以这样优化:</p> <pre> <code class="language-javascript">var fs = require('fs-modified'); function internalReadFileCallback(error, result) { // 修改后的 readFile 函数将上下文对象设置为 `this` // 并调用原来传来的 callback if (error) { return this(error); } // 需要 try-catch 来处理可能存在的非法 JSON 造成的语法错误 try { var json = JSON.parse(result); this(null, json); } catch (e) { this(e); } } function readFileAsJson(fileName, callback) { // 修改后的 fs.readFile 接收上下文对象作为第四个参数 // 但实际无需为 `callback` 单独创建一个普通对象 // 直接将其作为上下文对象即可 fs.readFile(fileName, 'utf8', internalReadFileCallback, callback); }</code></pre> <p>显然,我们需要从内部、使用两个方面控制 API,这种优化对那些不接收上下文对象作为参数的 API 来说,全无用处。但当我们控制了多个内部层的时候,性能优化的收益则极为可观。顺便提一个经常被忽略的细节:JavaScript 数组的某些内置 API(如 forEach)可以接收一个上下文对象作为第二个参数。</p> <h3>2. 减小对象体积</h3> <p>减小经常、频繁使用的对象(如 Promise)的体积至关重要。对象被分配在栈(heap)中,对象体积越大,栈空间也会越快被占满,回收器要做的工作也更多。通常来说,对象体积越小,回收器判断对象状态时要访问的字段也就越少。</p> <p>使用位运算符,布尔值 and/or 特定整数字段能够包装到更小的空间中。JavaScript 采用 32 位整数,所以可以将 32 个布尔字段(或 8 个 4 位整数字段,又或者 16 个布尔和 2 个 8 位整数字段 etc.)打包到一个字段中。为维护代码可读性,每个逻辑字段需要一对 getter/setter,用来对物理字段进行相关位运算操作。下面的例子展示如何使用整数保存一个布尔字段(未来还可扩展到多个逻辑字段):</p> <pre> <code class="language-javascript">// 使用 1 << 1 代表第二位, 1 << 2 代表第三位,依此类推 const READONLY = 1 << 0; class File { constructor() { this._bitField = 0; } isReadOnly() { // 圆括号不可省略 return (this._bitField & READONLY) !== 0; } setReadOnly() { this._bitField = this._bitField | READONLY; } unsetReadOnly() { this._bitField = this._bitField & (~READONLY); } }</code></pre> <p>访问器方法如此短小,运行时很可能会被内联,所以也不会产生额外开销。</p> <p>两个乃至多个不会同时用到的字段也可以合并成一个字段,用一个布尔值记录该字段所记录的值的类型即可。不过,如果像前面所讲的那样,将这个布尔字段打包在某个整数字段中,这样做的结果,无非只是节省了一些空间。</p> <p>Bluebird 在保存一个 Promise 对象的完成值与拒绝理由时就用到这种技巧。如果该Promise 对象完成,则使用该字段记录完成值,反之亦然。重复一遍,属性访问必须通过访问器函数,将丑陋的优化字节隐藏在底层。</p> <p>如果对象需要保存一个列表,尽量避免使用数组,直接使用索引属性,将值保存在对象上即可。</p> <p>不要这样做:</p> <pre> <code class="language-javascript">class EventEmitter { constructor() { this.listeners = []; } addListener(fn) { this.listeners.push(fn); } }</code></pre> <p>应尽量避免使用数组:</p> <pre> <code class="language-javascript">class EventEmitter { constructor() { this.length = 0; } addListener(fn) { var index = this.length; this.length++; this[index] = fn; } }</code></pre> <p>若 length 字段被限制为一个小的整数(如 10 位,限制 event emitter 的监听器数量最大为 1024),则还可以与其他布尔字段、特定整数字段打包在一起。</p> <h3>3. 可选特性懒重写</h3> <p>Bluebird 提供了有些可选特性,使用它们时可能拉低整个库的性能。这些特性主要包括警告、long stack trace、取消、 Promise.prototype.bind 以及 Promise 状态监控等。实现这些特性,须在整个库的不同地方调用不同的钩子函数。比如说,要实现 Promise 监控,那么每次创建 Promise 对象时就要调用某个函数。</p> <p>在调用钩子函数之前,当然最好先检查是否需要启用监控特性,这比不管三七二十一直接调用要靠谱。不过借助于内联缓存和内联函数,对未启用这些特性的用户来说,影响其实可以完全忽略。将初始钩子函数设置为空函数即可达到目的:</p> <pre> <code class="language-javascript">class Promise { // ... constructor(executor) { // ... this._promiseCreatedHook(); } // 空方法 _promiseCreatedHook() {} }</code></pre> <p>如果用户并未启用监控特性,优化器发现函数是什么都没干,便会忽略它。所以实际上可以认为 constructor 中的钩子函数不存在。</p> <p>那么如何启用相关特性呢?重写相关的空函数就可以啦:</p> <pre> <code class="language-javascript">function enableMonitoringFeature() { Promise.prototype._promiseCreatedHook = function() { // 实际实现 }; // ... }</code></pre> <p>这样的函数重写会使所有的 Promise 对象内联缓存失效,因此应该只在应用启动时,任何 Promise 对象创建之前进行重写。这样一来,空钩子函数就不会有任何影响了。</p> <h3>译者补充</h3> <p>拖拖拉拉,终于把这篇文章翻译出来了。需要说明的是,没有完全按照原文逐字翻译,插入了自己的一些理解。</p> <p>遗憾的是,有一部分名词实在不好翻译,所以本文难免有一些生硬的地方。虽然译者可以摸着良心说,真的已经尽了最大的努力。</p> <p> </p> <p> </p> <p>来自:http://www.zcfy.cc/article/three-javascript-performance-fundamentals-that-make-bluebird-fast-1209.html</p> <p> </p>