Bluebird 高性能揭秘

MalloryU29 7年前發布 | 10K 次閱讀 高性能 JavaScript開發 JavaScript

Bluebird 是一個廣泛使用的 Promise 庫,最早在 2013 年得到人們的關注。相比其他同等水平的 Promise 庫,Bluebird 快了一百來倍。Bluebird 自始至終遵循著 JavaScript 優化的一些基本原則,所以才有這么好的性能。本文將會介紹其中最有價值的三個方面。

1. 函數中的對象分配最小化

對象分配(object allocation),尤其是函數中的對象分配,對性能的影響是很大的,因為其實現需要用到大量內部數據。JavaScript 實現了垃圾自動回收,占用內存的不單是分配的對象;垃圾回收器也有份,它在不斷尋找那些不再使用的對象,以釋放內存。JavaScript 占用內存越多,垃圾回收需要的 CPU 資源也就越多,這樣一來,運行代碼的 CPU 資源就會減少。

函數是 JavaScript 中的一等對象,和其他對象有著相同的特性。假設在函數 fnA 中,聲明了另一個函數 fnB,那么每次調用外層的 fnA 時,都會有一個全新的 fnB 函數對象被創建,哪怕兩次代碼完全一樣。請看下面的例子:

function trim(string) {
    function trimStart(string) {
        return string.replace(/^\s+/g, "");
    }

    function trimEnd(string) {
        return string.replace(/\s+$/g, "");
    }

    return trimEnd(trimStart(string))
}

每次調用 trim 函數的時候,兩個并非必需的函數對象(trimStart 和 trimEnd 函數)就會被創建出來。說這兩個函數對象并非必需,是因為它們作為獨特對象的特點并未起到絲毫作用,如屬性賦值、變量隱藏等,所用到的僅僅是它們的內部功能而已。

要優化這個例子并不麻煩,將那兩個函數移到 trim 函數之外就好。它們同處于相同模塊,只會加載一次,所以這兩個函數各自只會創建一個函數對象:

function trimStart(string) {
    return string.replace(/^\s+/g, "");
}

function trimEnd(string) {
    return string.replace(/\s+$/g, "");
}

function trim(string) {
    return trimEnd(trimStart(string))
}

但更為常見的情況是,函數對象似乎是一種必要之惡,優化并不像上面這般簡單。比如說,傳遞回調函數時,總是需要考慮特定上下文。這通常可以用閉包實現,簡單又直觀,效率卻極低。舉個小例子,使用 Node 讀取 JSON 文件:

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);
        }
    })
}

在上面的例子中,傳給 fs.readFile 的匿名回調,是不能從 readFileAsJson 函數中提取出來的,因為該匿名函數能夠訪問其外部的 callback 變量。需要注意的是,即便使用命名函數取代匿名函數,也不會有任何區別。

Bluebird 內部常用到的優化方法,是采用明確的普通對象保存與上下文相關的數據。對一次包含逐層傳遞 callback 的操作來說,只需分配一次對象。相比每當 callback 傳入另一層函數時就需要創建新閉包,優化方法只需要傳遞一個額外的參數。假設某個操作調用 callback 分五步進行,若使用閉包則意味著要分配五個函數對象外加五個上下文對象,而使用優化方法則只需要一個普通對象。

假如可以修改 fs.readFile API,使其接收一個上下文對象,那么前面的例子可以這樣優化:

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);
}

顯然,我們需要從內部、使用兩個方面控制 API,這種優化對那些不接收上下文對象作為參數的 API 來說,全無用處。但當我們控制了多個內部層的時候,性能優化的收益則極為可觀。順便提一個經常被忽略的細節:JavaScript 數組的某些內置 API(如 forEach)可以接收一個上下文對象作為第二個參數。

2. 減小對象體積

減小經常、頻繁使用的對象(如 Promise)的體積至關重要。對象被分配在棧(heap)中,對象體積越大,棧空間也會越快被占滿,回收器要做的工作也更多。通常來說,對象體積越小,回收器判斷對象狀態時要訪問的字段也就越少。

使用位運算符,布爾值 and/or 特定整數字段能夠包裝到更小的空間中。JavaScript 采用 32 位整數,所以可以將 32 個布爾字段(或 8 個 4 位整數字段,又或者 16 個布爾和 2 個 8 位整數字段 etc.)打包到一個字段中。為維護代碼可讀性,每個邏輯字段需要一對 getter/setter,用來對物理字段進行相關位運算操作。下面的例子展示如何使用整數保存一個布爾字段(未來還可擴展到多個邏輯字段):

// 使用 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);
    }
}

訪問器方法如此短小,運行時很可能會被內聯,所以也不會產生額外開銷。

兩個乃至多個不會同時用到的字段也可以合并成一個字段,用一個布爾值記錄該字段所記錄的值的類型即可。不過,如果像前面所講的那樣,將這個布爾字段打包在某個整數字段中,這樣做的結果,無非只是節省了一些空間。

Bluebird 在保存一個 Promise 對象的完成值與拒絕理由時就用到這種技巧。如果該Promise 對象完成,則使用該字段記錄完成值,反之亦然。重復一遍,屬性訪問必須通過訪問器函數,將丑陋的優化字節隱藏在底層。

如果對象需要保存一個列表,盡量避免使用數組,直接使用索引屬性,將值保存在對象上即可。

不要這樣做:

class EventEmitter {
    constructor() {
        this.listeners = [];
    }

    addListener(fn) {
        this.listeners.push(fn);
    }
}

應盡量避免使用數組:

class EventEmitter {
    constructor() {
        this.length = 0;
    }

    addListener(fn) {
        var index = this.length;
        this.length++;
        this[index] = fn;
    }
}

若 length 字段被限制為一個小的整數(如 10 位,限制 event emitter 的監聽器數量最大為 1024),則還可以與其他布爾字段、特定整數字段打包在一起。

3. 可選特性懶重寫

Bluebird 提供了有些可選特性,使用它們時可能拉低整個庫的性能。這些特性主要包括警告、long stack trace、取消、 Promise.prototype.bind 以及 Promise 狀態監控等。實現這些特性,須在整個庫的不同地方調用不同的鉤子函數。比如說,要實現 Promise 監控,那么每次創建 Promise 對象時就要調用某個函數。

在調用鉤子函數之前,當然最好先檢查是否需要啟用監控特性,這比不管三七二十一直接調用要靠譜。不過借助于內聯緩存和內聯函數,對未啟用這些特性的用戶來說,影響其實可以完全忽略。將初始鉤子函數設置為空函數即可達到目的:

class Promise {
    // ...
    constructor(executor) {
        // ...
        this._promiseCreatedHook();
    }

    // 空方法
    _promiseCreatedHook() {}
}

如果用戶并未啟用監控特性,優化器發現函數是什么都沒干,便會忽略它。所以實際上可以認為 constructor 中的鉤子函數不存在。

那么如何啟用相關特性呢?重寫相關的空函數就可以啦:

function enableMonitoringFeature() {
    Promise.prototype._promiseCreatedHook = function() {
        // 實際實現
    };

    // ...
}

這樣的函數重寫會使所有的 Promise 對象內聯緩存失效,因此應該只在應用啟動時,任何 Promise 對象創建之前進行重寫。這樣一來,空鉤子函數就不會有任何影響了。

譯者補充

拖拖拉拉,終于把這篇文章翻譯出來了。需要說明的是,沒有完全按照原文逐字翻譯,插入了自己的一些理解。

遺憾的是,有一部分名詞實在不好翻譯,所以本文難免有一些生硬的地方。雖然譯者可以摸著良心說,真的已經盡了最大的努力。

 

 

來自:http://www.zcfy.cc/article/three-javascript-performance-fundamentals-that-make-bluebird-fast-1209.html

 

 本文由用戶 MalloryU29 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!