正如我們在近期的文章《 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 中,函式是第一類物件。這意味著它們和任何其它物件一樣,具有相同的特徵和屬性。如果你有一個包含了另一個或多個函式的程式碼宣告的函式,那麼對父函式的每一次呼叫都會建立新的、唯一的函式物件,儘管執行了相同的程式碼。如下是一個基本的例子:
1 2 3 4 5 6 7 8 9 |
function trim(string) { function trimStart(string) { return string.replace(/^s+/g, ""); } function trimEnd(string) { return string.replace(/s+$/g, ""); } return trimEnd(trimStart(string)) } |
現在每次呼叫 trim 函式,都會建立兩個不必要的函式物件來表示 trimStrat 和 trimEnd。函式物件是不必要的,因為它們並不用於物件的唯一標識,例如屬性賦值或變數封裝。他們只用於程式碼所包含的功能。
這個例子很容易優化,只需簡單地把這些函式移出 trim 。由於示例包含在模組當中,並且模組僅為程式載入一次,所以函式將只存在一種表現形式:
1 2 3 4 5 6 7 8 9 10 11 |
function trimStart(string) { return string.replace(/^s+/g, ""); } function trimEnd(string) { return string.replace(/s+$/g, ""); } function trim(string) { return trimEnd(trimStart(string)) } |
然而,更多常見的函式物件似乎是一個無法避免的弊病,沒辦法這麼輕易地優化。例如,任何時候你傳遞一個稍後會被呼叫的回撥函式,幾乎總是需要一個特定的上下文來進行回撥。通常情況下,這種上下文以簡單而直觀但低效的閉包方式來實現。一個簡單的例子就是使用預設的非同步回撥介面在節點中讀取一個 JSON 檔案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
var fs = require('fs'); function readFileAsJson(fileName, callback) { fs.readFile(fileName, 'utf8', function(error, result) { // This is a new function object created every time readFileAsJson is called //每當 readFileAsJson 被呼叫,都會建立新的函式物件 // Since it's a closure, an internal Context object is also // allocated for the closure state //由於這是一個閉包,一個內部的上下文物件也會分配給這個閉包狀態 if (error) { return callback(error); } // The try-catch block is needed to handle a possible syntax error from invalid JSON //try-catch塊用於處理由於無效的JSON檔案導致的可能出現的語法錯誤 try { var json = JSON.parse(result); callback(null, json); } catch (e) { callback(e); } }) } |
在這個例子中,傳遞給 fs.readFile 的回撥函式不能被移出 readFileAsJson,因為它通過唯一的變數回撥建立了一個閉包。還應該注意的是,fs.readFile 無論是作為命名函式宣告還是內聯匿名函式來都沒什麼區別。
很大程度上 Bluebird 內部使用的優化是使用顯示的簡單物件來儲存上下文資料。由一個通過多層的回撥組成的操作,只需要一次這樣的物件分配。每次將回撥傳遞到另一個層時,每個層將不會建立一個新的閉包,而是將顯式的簡單物件作為一個額外的引數進行傳遞。舉個例子,假設一個操作中有五個回撥的步驟,使用閉包意味著分配五個函式物件和上下文物件,但是如果使用這個plain object方法來優化的話,總共只分配一個 plain object 就可以了。
我們可以修改一下 fs.readFile API,讓它接受一個上下文物件,在剛剛的例子中使用這種優化方法以後,程式碼看起來就是這樣的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
var fs = require('fs-modified'); function internalReadFileCallback(error, result) { // The modified readFile calls the callback with the context object set to `this`, // which is just the original client's callback function //重新修改後的 readFile 呼叫了帶有設定為 “this” 的上下文物件的回撥函式 //也就是原始的客戶端的回撥函式 if (error) { return this(error); } // The try-catch block is needed to handle a possible syntax error from invalid JSON //try-catch塊用於處理由於無效的JSON檔案導致的可能出現的語法錯誤 try { var json = JSON.parse(result); this(null, json); } catch (e) { this(e); } } function readFileAsJson(fileName, callback) { // The modified fs.readFile would take the context object as 4th argument. //修改後的 fs.readFile 會將這個上下文物件作為第四個引數 // There is no need to create a separate plain object to contain `callback` so it //這裡沒有必要建立一個獨立的 plain object 來包含 callback // can just be made the context object directly. //可以直接作為這個上下文物件 fs.readFile(fileName, 'utf8', internalReadFileCallback, callback); } |
很明顯,你需要同時控制 API 的兩端,這使得這種優化方法對於不接受上下文物件引數的 API 不可行。然而,當你可以使用這種方法的時候(比如當你控制多個內部層的時候),這會帶來非常大的效能提升。還有一個已知的事實:像 Array.prototype.forEach 這樣的一些內建的 JavaScript 陣列 API 可以接受一個上下文物件作為第二個引數。
2. 物件大小最小化
最小化那些經常並大量分配的物件(如 promises)的大小是至關重要的。在最常用的 JavaScript 實現中,物件分配的堆被分成不同的段和空間。較小的物件比較大的物件需要更多的時間來填滿這些空間和段,從而減少了垃圾回收器的工作量。在決定物件有效或失效時,較小的物件可以使垃圾回收器訪問更少的欄位。
布林和/或受限制的整數字段時,可以通過位運算子打包到一個更小的空間。JavaScript 位運算子可以操作 32 位的整數,因此你可以將 32 個布林欄位或 8 個 4 位的整數或 16 個布林值和 2 個 8 位的整數等合併到一個欄位中。為了保持程式碼的可讀性,每一個邏輯欄位都應該有一對 getter 和 setter 函式來執行物理欄位的正確的位操作。將一個布林欄位打包為一個整數(未來可以擴大到容納更多的邏輯欄位)的例子如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// Use 1 << 1 for the second bit, 1 << 2 for the third bit etc. const READONLY = 1 << 0; class File { constructor() { this._bitField = 0; } isReadOnly() { // Parentheses are required. //括號是必須的 return (this._bitField & READONLY) !== 0; } setReadOnly() { this._bitField = this._bitField | READONLY; } unsetReadOnly() { this._bitField = this._bitField & (~READONLY); } } |
這個存取方法非常短,因此它們在執行時很有可能被內聯,從而不涉及函式呼叫的開銷。
當使用一個布林值來追蹤這個欄位儲存的是哪種型別的值的時候,兩個或多個從來不會同時使用的欄位就可以壓縮為一個欄位。然而,像之前描述的辦法一樣,將這個布林欄位作為一個打包好的整數字段來執行僅僅是節約了一些空間而已。
在 Bulebird 中,這個技巧被用來儲存 promise 執行的返回值或拒絕原因。這裡並沒有明顯的分界:如果 promise 執行成功,那麼這個返回值可能會被儲存在失敗的回撥函式的區域;請求失敗時,拒絕請求的原因也可能儲存在成功的回撥函式區域。再重複一次,所有的訪問都應該通過存取函式來隱藏這些難看的優化細節。
如果一個物件請求了一系列值,你可以通過將這些值直接儲存在物件的索引中,從而避免一個單獨的陣列分配。
所以,不要這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class EventEmitter { constructor() { this.listeners = []; } addListener(fn) { this.listeners.push(fn); } } |
你可以避免使用陣列:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class EventEmitter { constructor() { this.length = 0; } addListener(fn) { var index = this.length; this.length++; this[index] = fn; } } |
如果 .length 可以被限制到一個很小的整數(比如 10 位,這將會把事件發射器限制為最多 1024 個監聽器),那麼它就可以和其他的受限制的整數或布林值一起壓縮成為一個打包好的欄位。
3. 使用空函式並簡單地覆蓋它們來實現代價昂貴的可選功能
Bluebird 有一些可選的功能特性在使用時會導致整個庫的統一的效能損失。這些功能是:警告,長的棧追蹤,取消操作,Promise.prototype.bind 和 promise 狀態監控。這些功能會在庫中呼叫鉤子函式(hook functions)。舉個例子,每當建立一個 promise 物件的時候,promise 監控功能都要被呼叫一次。
比起不管監控功能是否啟用都呼叫鉤子函式,在呼叫前先檢查一下監控功能是否啟用要好得多。然而,由於內建快取和函式內聯,對於不啟用該功能的使用者,實際上可以完全消除成本。這可以通過將原始的 hook 方法設定為空操作函式來實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class Promise { // ... constructor(executor) { // ... this._promiseCreatedHook(); } // Just an empty no-op method. _promiseCreatedHook() {} } |
現在,如果使用者沒有啟用監控功能,優化器就會發現這個函式呼叫不執行任何操作,並將其消除。所以對 constructor 中 hook 方法的有效呼叫並不存在。
為了使這個功能真正地執行,啟用該功能時必須用真正的實現覆蓋所有相關的空操作函式:
1 2 3 4 5 6 7 8 9 10 11 |
function enableMonitoringFeature() { Promise.prototype._promiseCreatedHook = function() { // Actual implementation here }; // ... } |
像這樣的覆蓋方法將會使所有 Promise 類的物件建立的內建快取失效,因此只應該在啟動應用程式時,在所有 promise 物件建立以前執行。如果在任一記憶體使用之前發生了覆蓋,那麼在功能被啟用後的操作建立的內建快取也不會識別到這些空操作函式的存在。
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!