try catch引發的性能優化深度思考

關鍵程式碼拆解成如下圖所示(無關部分已省略):

demo

起初我認為可能是這個 getRowDataItemNumberFormat 函數裡面某些方法執行太慢,從 formatData.replaceunescape(已廢棄,官方建議使用 decodeURI 或者 decodeURIComponent 替代) 方法都懷疑了一遍,發現這些方法都不是該函數運行慢的原因。為了深究原因,我給 style.formatData 傳入了不同的值,發現這個函數的運行效率出現不同的表現。開始有點疑惑為什麼 style.formatData 的值導致這個函數的運行效率差別如此之大。

進一步最終定位發現如果 style.formatData 為 undefined 的時候,效率驟降,如果 style.formatData 為合法的字元串的時候,效率是正常值。我開始意識到這個問題的原因在那裡了,把目光轉向了 try catch 程式碼塊,這是一個很可疑的地方,在很早之前曾經聽說過不合理的 try catch 是會影響性能的,但是之前從沒遇到過,結合了一些資料,我發現比較少案例去探究這類程式碼片段的性能,我決定寫程式碼去驗證下:

window.a = 'a';
window.c = undefined;
function getRowDataItemNumberFormatTryCatch() {
    console.time('getRowDataItemNumberFormatTryCatch');
    for (let i = 0; i < 3000; i++) {
        try {
            a.replace(/%022/g, '"');
        }
        catch (error) {
        }
    }
    console.timeEnd('getRowDataItemNumberFormatTryCatch');
}

我嘗試把 try catch 放入一個 for 循環中,讓它運行 3000 次,看看它的耗時為多少,我的電腦執行該程式碼的時間大概是 0.2 ms 左右,這是一個比較快的值,但是這裡 a.replace 是正常運行的,也就是 a 是一個字元串能正常運行 replace 方法,所以這裡的耗時是正常的。我對他稍微做了一下改變,如下:

function getRowDataItemNumberFormatTryCatch2() {
    console.time('getRowDataItemNumberFormatTryCatch');
    for (let i = 0; i < 3000; i++) {
        try {
            c.replace(/%022/g, '"');
        }
        catch (error) {
        }
    }
    console.timeEnd('getRowDataItemNumberFormatTryCatch');
}

這段程式碼跟上面程式碼唯一的區別是,c.replace 此時應該是會報錯的,因為 cundefined,這個錯誤會被 try catch 捕捉到,而上面的程式碼耗時出現了巨大的變化,上升到 40 ms,相差了將近 200 倍!並且上述程式碼和首圖的 getRowDataItemNumberFormat 函數程式碼均出現了 Minor GC,注意這個 Minor GC 也是會耗時的。

demo

這可以解釋一部分原因了,我們上面運行的程式碼是一個性能比較關鍵的部分,不應該使用 try catch 結構,因為該結構是相當獨特的。與其他構造不同,它運行時會在當前作用域中創建一個新變數。每次 catch 執行該子句都會發生這種情況,將捕獲的異常對象分配給一個變數。

即使在同一作用域內,此變數也不存在於腳本的其他部分中。它在 catch 子句的開頭創建,然後在子句末尾銷毀。因為此變數是在運行時創建和銷毀的(這些都需要額外的耗時!),並且這是 JavaScript 語言的一種特殊情況,所以某些瀏覽器不能非常有效地處理它,並且在捕獲異常的情況下,將捕獲處理程式放在性能關鍵的循環中可能會導致性能問題,這是我們為什麼上面會出現 Minor GC 並且會有嚴重耗時的原因。

如果可能,應在程式碼中的較高級別上進行異常處理,在這種情況下,異常處理可能不會那麼頻繁發生,或者可以通過首先檢查是否允許所需的操作來避免。上面的 getRowDataItemNumberFormatTryCatch2 函數示例顯示的循環,如果裡面所需的屬性不存在,則該循環可能引發多個異常,為此性能更優的寫法應該如下:

function getRowDataItemNumberFormatIf() {
    console.time('getRowDataItemNumberFormatIf');
    for (let i = 0; i < 3000; i++) {
        if (c) {
            c.replace(/%022/g, '"');
        }
    }
    console.timeEnd('getRowDataItemNumberFormatIf')
}

上面的這段程式碼語義上跟 try catch 其實是相似的,但運行效率迅速下降至 0.04ms,所以 try catch 應該通過檢查屬性或使用其他適當的單元測試來完全避免使用此構造,因為這些構造會極大地影響性能,因此應盡量減少使用它們。

如果一個函數被重複調用,或者一個循環被重複求值,那麼最好避免其中包含這些構造。它們最適合僅執行一次或僅執行幾次且不在性能關鍵程式碼內執行的程式碼。儘可能將它們與其他程式碼隔離,以免影響其性能。

例如,可以將它們放在頂級函數中,或者運行它們一次並存儲結果,這樣你以後就可以再次使用結果而不必重新運行程式碼。

demo

getRowDataItemNumberFormat 在經過上述思路改造後,運行效率得到了質的提升,在實測 300 多次循環中減少的時間如下圖,足足優化了將近 2s 多的時間,如果是 3000 次的循環,那麼它的優化比例會更高:

demo
demo

由於上面的程式碼是從項目中改造出來演示的,可能並不夠直觀,所以我重新寫了另外一個相似的例子,程式碼如下,這裡面的邏輯和上面的 getRowDataItemNumberFormat 函數講道理是一致的,但是我讓其發生錯誤的時候進入 catch 邏輯執行任務。

事實上 plus1plus2 函數的程式碼邏輯是一致的,只有程式碼語義是不相同,一個是返回 1,另一個是錯誤拋出1,一個求和方法在 try 片段完成,另一個求和方法再 catch 完成,我們可以粘貼這段程式碼在瀏覽器分別去掉不同的注釋觀察結果。

我們發現 try 片段中的程式碼運行大約使用了 0.1 ms,而 catch 完成同一個求和邏輯卻執行了大約 6 ms,這符合我們上面程式碼觀察的預期,如果把計算範圍繼續加大,那麼這個差距將會更加明顯,實測如果計算 300000 次,那麼將會由原來的 60 倍差距擴大到 500 倍,那就是說我們執行的 catch 次數越少折損效率越少,而如果我們執行的 catch 次數越多那麼折損的效率也會越多。

所以在不得已的情況下使用 try catch 程式碼塊,也要盡量保證少進入到 catch 控制流分支中。

const plus1 = () => 1;
const plus2 = () => { throw 1 };
console.time('sum');
let sum = 0;
for (let i = 0; i < 3000; i++) {
    try {
        // sum += plus1(); // 正確時候 約 0.1ms
        sum += plus2(); // 錯誤時候 約 6ms
    } catch (error) {
        sum += error;
    }
}
console.timeEnd('sum');

上面的種種表現進一步引發了我對項目性能的一些思考,我搜了下我們這個項目至少存在 800 多個 try catch,糟糕的是我們無法保證所有的 try catch 是不損害程式碼性能並且有意義的,這裡面肯定會隱藏著很多上述類的 try catch 程式碼塊。

從性能的角度來看,目前 V8 引擎確實在積極的通過 try catch 來優化這類程式碼片段,在以前瀏覽器版本中上面整個循環即使發生在 try catch 程式碼塊內,它的速度也會變慢,因為以前瀏覽器版本會默認禁用 try catch 內程式碼的優化來方便我們調試異常。

try catch 需要遍歷某種結構來查找 catch 處理程式碼,並且通常以某種方式分配異常(例如:需要檢查堆棧,查看堆資訊,執行分支和回收堆棧)。儘管現在大部分瀏覽器已經優化了,我們也盡量要避免去寫出上面相似的程式碼,比如以下程式碼:

try {
    container.innerHTML = "I'm alloyteam";
}
catch (error) {
    // todo
}

上面這類程式碼我個人更建議寫成如下形式,如果你實際上拋出並捕獲了一個異常,它可能會變慢,但是由於在大多數情況下上面的程式碼是沒有異常的,因此整體結果會比異常更快。

這是因為程式碼控制流中沒有分支會降低運行速度,換句話說就是這個程式碼執行沒錯誤的時候,沒有在 catch 中浪費你的程式碼執行時間,我們不應該編寫過多的 try catch 這會在我們維護和檢查程式碼的時候提升不必要的成本,有可能分散並浪費我們的注意力。

當我們預感程式碼片段有可能出錯,更應該是集中注意力去處理 successerror 的場景,而非使用 try catch 來保護我們的程式碼,更多時候 try catch 反而會讓我們忽略了程式碼存在的致命問題。

if (container) container.innerHTML = "I'm alloyteam";
else // todo

在簡單程式碼中應當減少甚至不用 try catch ,我們可以優先考慮 if else 代替,在某些複雜不可測的程式碼中也應該減少 try catch(比如非同步程式碼),我們看過很多 asyncawait 的示例程式碼都是結合 try catch 的,在很多性能場景下我認為它並不合理,個人覺得下面的寫法應該是更乾淨,整潔和高效的。

因為 JavaScript 是事件驅動的,雖然一個錯誤不會停止整個腳本,但如果發生任何錯誤,它都會出錯,捕獲和處理該錯誤幾乎沒有任何好處,程式碼主要部分中的 try catch 程式碼塊是無法捕獲事件回調中發生的錯誤。

通常更合理的做法是在回調方法通過第一個參數傳遞錯誤資訊,或者考慮使用 Promisereject() 來進行處理,也可以參考 node 中的常見寫法如下:

;(async () => {
    const [err, data] = await readFile();
    if (err) {
        // todo
    };
})()

fs.readFile('<directory>', (err, data) => {
    if (err) {
        // todo
    }
});

結合了上面的一些分析,我自己做出一些淺顯的總結:

    1. 如果我們通過完善一些測試,盡量確保不發生異常,則無需嘗試使用 try catch 來捕獲異常。
    1. 非異常路徑不需要額外的 try catch,確保異常路徑在需要考慮性能情況下優先考慮 if else,不考慮性能情況請君隨意,而非同步可以考慮回調函數返回 error 資訊對其處理或者使用 Promse.reject()
    1. 應當適當減少 try catch 使用,也不要用它來保護我們的程式碼,其可讀性和可維護性都不高,當你期望程式碼是異常時候,不滿足上述1,2的情景時候可考慮使用。

最後,筆者希望這篇文章能給到你我一些方向和啟發吧,如有疏漏不妥之處,還請不吝賜教!附筆記鏈接,閱讀往期更多優質文章可移步查看,喜歡的可以給我點贊鼓勵哦://github.com/Wscats/CV/issues/33