如何優化JavaScriptCore

2020, where JavaScriptCore to go?

如何優化 JavaScriptCore

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

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

JavaScriptCore,越來越雞肋的存在

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

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

結果如下:

V8 Ntiro JavaScriptCore
87ms 271ms 591ms

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

不支持 JIT

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

不支持 asm.js 和 wasm

asm.js 是 JavaScript 的一個高度優化的子集,asm.js 來源于 Emscripten 編譯器項目。Emscripten 實現了 C/C++編譯成 JavaScript,輸出結果就是 asm.js。

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

雖然 asm.js 是編譯器輸出的結果,但是了解其規則是可以手寫出來相關代碼的。由于 JavaScriptCore 不支持 JIT,我本來想重寫小游戲的 JS 基礎庫,把高頻調用的一些函數改成 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++轉成二進制格式的類匯編代碼,Wasm 對前端來說非常重要,有了 Wasm,瀏覽器就可以對接大量已有的 C++庫,并且擁有遠超 JS 版本的性能。現在已經有了不少游戲使用了 Wasm,從 Unity 發來的 Demo 來看,性能還是不錯的,瀏覽器不再只能玩簡單游戲。
Wasm 也是 Emscripten 的產物,但無法手寫,只能靠編譯生成,在 JS 里靠 WebAssembly 接口加載。

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

調試不方便

JavaScriptCore 的調試只能通過 Safari,但你經常用的話就會發現,總會有一些坑爹的小毛病:連不上手機的 JSContext,打不開 TimeLine,TimeLine 不顯示堆棧等。我現在每次查耗時,都得用筆記本編包去調試,iMac 長年 TimeLine 不顯示堆棧。

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

auto open JS inspect

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

JS inspector in mainthread

自帶的一些坑

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

所有接口底層都會加鎖

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

JSLock

這會有兩個潛在的影響:

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

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

創建虛擬機自動在當前線程創建 RunLoop

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

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

JavaScriptCore 性能優化的手段

JavaScriptCore 還是有一些優化手段的,雖然沒有 JIT,但項目還得繼續,性能還得優化……

比如我們可以借鑒 asm.js 的優化方式:

  1. 變量是靜態類型
  2. 利用 TypedArray 作為堆,傳遞數據,管理內存
  3. 沒有 GC

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

此外還有兩點:

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

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

batch command

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

將數據存儲在一個 TypedArray 中,TypedArray 自創建后底層內存地址就不會變了,JS 和 Native 都可以從中高效讀取合并的數據。

  1. JS 調 Native

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

JS調Native
  1. Native 調 JS

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

image.png

Avoid JSLock

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

JSNumber encode

所以,優化思路是這樣的:

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

Less Garbage collection

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

Seperate JS thread

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

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

這有點像系統底層渲染驅動的實現思路,CPU 接受到渲染指令,存入 CPU command queue,等待系統調度在合適的時機發送給 GPU command queue,最終的 GPU 執行時機是異步的。

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

thread state

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

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

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

JavaScriptCore 未來會怎樣?

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

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

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,156評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,401評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,069評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,873評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,635評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,128評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,203評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,365評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,881評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,733評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,935評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,475評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,172評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,582評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,821評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,595評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,908評論 2 372