如何優(yōu)化JavaScriptCore

2020, where JavaScriptCore to go?

如何優(yōu)化 JavaScriptCore

從我接觸 iOS 開發(fā)開始,和 JS 有關的動態(tài)化場景已經(jīng)起起伏伏好幾次了,這些年 JavaScriptCore 從只是用來做 bridge,到 RN,JSPatch。作為 iOS 上唯一可用的 JS 虛擬機,JavaScriptCore 確實承載了不少技術的輝煌,但是蘋果已經(jīng)長達 5 年沒有更新它了。2020 年了,JavaScriptCore 該何去何從? 從 Flutter 出來之后,Dart 的出現(xiàn)讓我們認識到,動態(tài)化的方案尤其是虛擬機這塊,真的該動一動了。

注意:我這里所說的 JavaScriptCore 是指 iOS 自帶的 JavaScriptCore framework, JavaScriptCore 本身分幾個版本,WK 用的叫 Nitro,蘋果一直在優(yōu)化,但我們無法直接使用

JavaScriptCore,越來越雞肋的存在

我非常理解為什么谷歌要選用 Dart 來做開發(fā)語言,除了要維護自己公司的生態(tài)外,JavaScriptCore 的性能根本無法滿足 Flutter 自繪 UI 的方案。對于 Flutter 團隊來說 JavaScriptCore 就是雞肋,棄了就意味和龐大的前端生態(tài)割裂,喪失系統(tǒng)的原生支持,但繼續(xù)使用實在是難受,蘋果對 JavaScriptCore 的態(tài)度幾乎讓人絕望,故意做了很多使用的限制,讓人有種在破輪子上造新車的感覺。

JavaScriptCore 的幾大劣勢

性能差

把下面的代碼片段放在不同的 JS 引擎測試性能。

! function () {
    function caculate(x) {
        var sin = Math.sin(x);
        var cos = Math.cos(x);
        return Math.pow(sin, 2) + Math.pow(cos, 2);
    }

    var data = new Array(1000);
    for (var i = 0; i < data.length; i++) {
        data[i] = Math.PI * Math.random();
    }

    var ret = new Array(data.length);
    const start = new Date();
    for (var i = 0; i < data.length; i++) {
        ret = caculate(data);
    }
    const end = new Date();
    console.log("all caculte cost " + (end - start));
}();

結(jié)果如下:

V8 Ntiro JavaScriptCore
87ms 271ms 591ms

可以看到就算是 WK 使用的 Nitro,性能也要比 V8 差一倍左右,更別說閹割版的 JavaScriptCore 了,V8 的性能是它的七八倍。要是和 Dart 這種支持 AOT 的語言相比更是難以望其項背。

不支持 JIT

WK 使用的 Nitro 會根據(jù)函數(shù)或循環(huán)執(zhí)行的次數(shù),利用 OSR 使用不同優(yōu)化級別的機器碼,而 V8 更激進,會優(yōu)先考慮做 JIT 編譯。可惜 JavaScriptCore framework 閹割了 JIT,只靠它的 LLINT 解釋器解釋執(zhí)行。JIT 的作用是非常明顯的,如果在 JS 做骨骼動畫這種比較復雜的計算,有 JIT 的話小游戲幀率能保持在 30 幀左右,而沒有 JIT 只能再 4 幀左右。

不支持 asm.js 和 wasm

asm.js 是 JavaScript 的一個高度優(yōu)化的子集,asm.js 來源于 Emscripten 編譯器項目。Emscripten 實現(xiàn)了 C/C++編譯成 JavaScript,輸出結(jié)果就是 asm.js。

asm.js 的特點是變量是靜態(tài)類型,且用一個 TypedArray 管理內(nèi)存,帶來的好處是執(zhí)行性能更好。當解釋器遇到 asm.js 代碼時,可以解釋成更為高效的機器碼。

雖然 asm.js 是編譯器輸出的結(jié)果,但是了解其規(guī)則是可以手寫出來相關代碼的。由于 JavaScriptCore 不支持 JIT,我本來想重寫小游戲的 JS 基礎庫,把高頻調(diào)用的一些函數(shù)改成 asm, 但沒想到蘋果連 asm.js 都不給支持。

比如這個方法:

function asmCaculate(array) {
        'use asm'
        var int1 = array[0] | 0;
        var int2 = array[1] | 0;
        var int3  = array[2] | 0;
        var int4 = array[3] | 0;
        var float1 = +(array[4]);
        var float2 = +array[5];
        var float3 = +array[6];
        var float4 = +array[7];

        return +Math.exp((int1 - int2 + int3 - int4) | 0) + +Math.exp(+(float1 - float2 + float3 - float4));
}

在支持 asm 的 JS 引擎,耗時會比普通版本少個 10%左右,但在 JavaScriptCore 上反而會更高。因為 JavaScriptCore 把 asm 降級處理了,由于代碼長度比普通版本長,反而解釋起來更耗時了……

Wasm 是 asm.js 的進階版,直接將 C/C++轉(zhuǎn)成二進制格式的類匯編代碼,Wasm 對前端來說非常重要,有了 Wasm,瀏覽器就可以對接大量已有的 C++庫,并且擁有遠超 JS 版本的性能。現(xiàn)在已經(jīng)有了不少游戲使用了 Wasm,從 Unity 發(fā)來的 Demo 來看,性能還是不錯的,瀏覽器不再只能玩簡單游戲。
Wasm 也是 Emscripten 的產(chǎn)物,但無法手寫,只能靠編譯生成,在 JS 里靠 WebAssembly 接口加載。

雖然 JavaScriptCore 有 WebAssembly 接口,但被閹割了,一實例化就失敗,無法生成對應的 module,坑爹的是文檔也不提示,就這么霸氣,直接底層 API 封堵,我說你至少在 JS 把 API 抹掉也行啊!

調(diào)試不方便

JavaScriptCore 的調(diào)試只能通過 Safari,但你經(jīng)常用的話就會發(fā)現(xiàn),總會有一些坑爹的小毛?。哼B不上手機的 JSContext,打不開 TimeLine,TimeLine 不顯示堆棧等。我現(xiàn)在每次查耗時,都得用筆記本編包去調(diào)試,iMac 長年 TimeLine 不顯示堆棧。

此外,最好不要開自動打開 JSContext inpector,因為開著 inpector JSContext 是不會釋放的,對應的資源都泄漏,容易碰到奇怪的內(nèi)存問題。

auto open JS inspect

最坑的是,如果你用 Safari 斷點調(diào)試 JSContext,那么 JS 的執(zhí)行線程會變!這在多線程的環(huán)境下簡直是讓人崩潰,尤其是小游戲這種,渲染模塊對線程非常敏感,所以最好是當 JS 環(huán)境穩(wěn)定了再開 JSContext inspector,或者不要在多線程模式下開。

JS inspector in mainthread

自帶的一些坑

JavaScriptCore 還有一些非常隱蔽的坑:

所有接口底層都會加鎖

JavaScriptCore 同一時間只有一個線程能夠訪問虛擬機,所以是線程安全的。但這意味著所有進入虛擬機的接口都會加鎖(實際上絕大部分接口都會進虛擬機),只有當退出虛擬機才會解鎖,這樣才能支持虛擬機被并發(fā)地調(diào)用。

JSLock

這會有兩個潛在的影響:

  1. 想做多線程的話只能用多個虛擬機來實現(xiàn),但不同虛擬機之間傳遞數(shù)據(jù)會比較麻煩

  2. 由于有隱含的 JSLock,所以要特別小心死鎖,尤其是主線程和輔助線程之間要盡量理清關系,一個虛擬機盡量只在一個線程使用。

創(chuàng)建虛擬機自動在當前線程創(chuàng)建 RunLoop

當虛擬機被初始化時,它會自動在當前線程創(chuàng)建一個自己的 RunLoop,定時去做一些回調(diào),最要命的是它要進入虛擬機,會有加鎖操作,而文檔沒有任何說明。

具體表現(xiàn)是,如果你在主線程創(chuàng)建了 JSContext,就算后期只在輔助線程使用,主線程依然會有一個 JS 的 RunLoop 定時回調(diào),并且會給主線程加鎖,如果這時剛好你的輔助線程需要同步主線程,就直接死鎖了。這種死鎖和業(yè)務代碼關系不大,查起來讓人摸不著頭腦。

JavaScriptCore 性能優(yōu)化的手段

JavaScriptCore 還是有一些優(yōu)化手段的,雖然沒有 JIT,但項目還得繼續(xù),性能還得優(yōu)化……

比如我們可以借鑒 asm.js 的優(yōu)化方式:

  1. 變量是靜態(tài)類型
  2. 利用 TypedArray 作為堆,傳遞數(shù)據(jù),管理內(nèi)存
  3. 沒有 GC

1 需要解釋器兼容,我們肯定是沒辦法了。但 2、3 還是可以作為一個優(yōu)化的方向。

此外還有兩點:

  1. JSLock 也很討厭,單線程使用時,加解鎖的性能白白損失了。
  2. 提高 JS-Native 的交互效率,提高單位時間的 JS-Native 的數(shù)據(jù)吞吐量

上面這些就是我的主要優(yōu)化思路,大致介紹下我是如何實現(xiàn)的,希望能有所幫助。

batch command

減少每一幀的 JS-Native 交互次數(shù),合并 JS-Native 之間傳遞的數(shù)據(jù)。只在必須時才做 JS-Native 的交互,避免兩個語言環(huán)境切換造成的性能損耗。

將數(shù)據(jù)存儲在一個 TypedArray 中,TypedArray 自創(chuàng)建后底層內(nèi)存地址就不會變了,JS 和 Native 都可以從中高效讀取合并的數(shù)據(jù)。

  1. JS 調(diào) Native

    將 JS 的指令、參數(shù)壓縮成一行一行的數(shù)字,寫入 TypedArray 里,當需要 Native 執(zhí)行時,通知 Native 讀取數(shù)據(jù),調(diào)用真正的函數(shù)。圖中綠色部分是會進 JS 虛擬機的操作,會有潛在的加解鎖。

JS調(diào)Native
  1. Native 調(diào) JS

    和上一條類似的原理,這里用我做手勢優(yōu)化的流程圖表示,手勢數(shù)據(jù)量大,且相對高頻,Naive 往 TypedArray 寫數(shù)據(jù),幀末通知 JS 取出數(shù)據(jù)做處理。

image.png

Avoid JSLock

通過閱讀 JSCore 的源碼,發(fā)現(xiàn) JS 的 Number 在生成時,會把值編碼到它的地址里,解析時也是靠解碼地址來解值,可以自己實現(xiàn)這個過程避免 JSLock,除此之外 JS 里的 undefined,null 都是固定值。TypedArray 也有個好處,初始化后它底層的地址不會改變,可以靠地址偏移還高效去數(shù)據(jù)。

JSNumber encode

所以,優(yōu)化思路是這樣的:

  1. JSNumber 可以自己構造,不用經(jīng)過虛擬機,干掉所有的JSValueMakeNumber,JSValueToNumberJSValueMakeNull等。
  2. 因為 JSNumber 不會被 GC,且傳遞相對高效,只需要編解碼地址,所以 JSObject 我們可以設置一個 JSNumber 作為句柄,JS 和 Native 靠這個句柄從緩存中取對象,不用經(jīng)過 JS 虛擬機
  3. 如果是一批 JSNumber 數(shù)據(jù),就將它們放入 TypedArray,這樣可以避免傳遞過多零散數(shù)據(jù)

Less Garbage collection

在 JS 層,對于高頻使用的對象,使用緩存來避免頻繁的 GC。尤其是要關注一些比較占內(nèi)存的對象比如 Array,Canvas 等,在 Native 的 GC 回調(diào),也要及時清理紋理、文件等資源,因為 JavaScriptCore 是按照當前設備的內(nèi)存壓力來判斷是否 GC 的。

Seperate JS thread

使用 JavaScriptCore 的項目一般是要動態(tài)化執(zhí)行 Native 邏輯,絕大多數(shù)情況下 JS-Native 這個流程是在一個線程完成的。

但是如果 JS 的邏輯很復雜,性能壓力很大,可以考慮把 JS 的執(zhí)行線程和 Native 的執(zhí)行線程分開,二者只在 JS 需要同步獲取信息時才做同步,否則就一直異步派發(fā)數(shù)據(jù)給 Native。

這有點像系統(tǒng)底層渲染驅(qū)動的實現(xiàn)思路,CPU 接受到渲染指令,存入 CPU command queue,等待系統(tǒng)調(diào)度在合適的時機發(fā)送給 GPU command queue,最終的 GPU 執(zhí)行時機是異步的。

小游戲渲染和 JS 耗時較大,我把 JS 和渲染抽成兩個獨立的線程:tt.js.thread, tt.render.thread,各自做對應的工作,UI工作放主線程,其他耗時操作靠GCD派發(fā)。這樣就提高了單位時間內(nèi) JS-Native 的數(shù)據(jù)吞吐量,從而提高幀率。

thread state

雖然這樣可以解決單線程的性能瓶頸,但是實際的實現(xiàn)難度非常大,所以放在最后。因為 OpenGL、JavaScriptCore 對多線程非常不友好,要保證它們在多線程環(huán)境下沒有問題真的太難了。

尤其要注意雖然JavaScriptCore的接口都是線程安全的,但JSObject不是線程安全的。如果JSObject/JSValue在其他線程使用,要注意延長它們的生命周期,因為在使用時可能會碰到虛擬機GC。

多線程要考慮好實現(xiàn)方案,盡量用最簡單的架構,同時要注意對線程敏感的接口,而且就算設想的很好,也要做好心理準備去面臨成噸的 Bug……

JavaScriptCore 未來會怎樣?

如果蘋果未來依然不更新 JavaScriptCore,不支持 JIT、Wasm,那么 JavaScriptCore 就無法再支持新技術的出現(xiàn)了。

Flutter 給了大家一種新思路,Dart 實現(xiàn)了一種 JIT 結(jié)合 AOT 開發(fā)的體驗。未來有可能出現(xiàn)支持 TS 的虛擬機,這樣就是大殺器了。

但還是期待蘋果能改進下對JavaScriptCore的支持政策,畢竟系統(tǒng)原生的包增量小,有獨立進程。2020年了,至少先給個JIT?

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內(nèi)容