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 是不會釋放的,對應的資源都泄漏,容易碰到奇怪的內存問題。
最坑的是,如果你用 Safari 斷點調試 JSContext,那么 JS 的執行線程會變!這在多線程的環境下簡直是讓人崩潰,尤其是小游戲這種,渲染模塊對線程非常敏感,所以最好是當 JS 環境穩定了再開 JSContext inspector,或者不要在多線程模式下開。
自帶的一些坑
JavaScriptCore 還有一些非常隱蔽的坑:
所有接口底層都會加鎖
JavaScriptCore 同一時間只有一個線程能夠訪問虛擬機,所以是線程安全的。但這意味著所有進入虛擬機的接口都會加鎖(實際上絕大部分接口都會進虛擬機),只有當退出虛擬機才會解鎖,這樣才能支持虛擬機被并發地調用。
這會有兩個潛在的影響:
想做多線程的話只能用多個虛擬機來實現,但不同虛擬機之間傳遞數據會比較麻煩
由于有隱含的 JSLock,所以要特別小心死鎖,尤其是主線程和輔助線程之間要盡量理清關系,一個虛擬機盡量只在一個線程使用。
創建虛擬機自動在當前線程創建 RunLoop
當虛擬機被初始化時,它會自動在當前線程創建一個自己的 RunLoop,定時去做一些回調,最要命的是它要進入虛擬機,會有加鎖操作,而文檔沒有任何說明。
具體表現是,如果你在主線程創建了 JSContext,就算后期只在輔助線程使用,主線程依然會有一個 JS 的 RunLoop 定時回調,并且會給主線程加鎖,如果這時剛好你的輔助線程需要同步主線程,就直接死鎖了。這種死鎖和業務代碼關系不大,查起來讓人摸不著頭腦。
JavaScriptCore 性能優化的手段
JavaScriptCore 還是有一些優化手段的,雖然沒有 JIT,但項目還得繼續,性能還得優化……
比如我們可以借鑒 asm.js 的優化方式:
- 變量是靜態類型
- 利用 TypedArray 作為堆,傳遞數據,管理內存
- 沒有 GC
1 需要解釋器兼容,我們肯定是沒辦法了。但 2、3 還是可以作為一個優化的方向。
此外還有兩點:
- JSLock 也很討厭,單線程使用時,加解鎖的性能白白損失了。
- 提高 JS-Native 的交互效率,提高單位時間的 JS-Native 的數據吞吐量
上面這些就是我的主要優化思路,大致介紹下我是如何實現的,希望能有所幫助。
batch command
減少每一幀的 JS-Native 交互次數,合并 JS-Native 之間傳遞的數據。只在必須時才做 JS-Native 的交互,避免兩個語言環境切換造成的性能損耗。
將數據存儲在一個 TypedArray 中,TypedArray 自創建后底層內存地址就不會變了,JS 和 Native 都可以從中高效讀取合并的數據。
-
JS 調 Native
將 JS 的指令、參數壓縮成一行一行的數字,寫入 TypedArray 里,當需要 Native 執行時,通知 Native 讀取數據,調用真正的函數。圖中綠色部分是會進 JS 虛擬機的操作,會有潛在的加解鎖。
-
Native 調 JS
和上一條類似的原理,這里用我做手勢優化的流程圖表示,手勢數據量大,且相對高頻,Naive 往 TypedArray 寫數據,幀末通知 JS 取出數據做處理。
Avoid JSLock
通過閱讀 JSCore 的源碼,發現 JS 的 Number 在生成時,會把值編碼到它的地址里,解析時也是靠解碼地址來解值,可以自己實現這個過程避免 JSLock,除此之外 JS 里的 undefined,null 都是固定值。TypedArray 也有個好處,初始化后它底層的地址不會改變,可以靠地址偏移還高效去數據。
所以,優化思路是這樣的:
- JSNumber 可以自己構造,不用經過虛擬機,干掉所有的
JSValueMakeNumber
,JSValueToNumber
,JSValueMakeNull
等。 - 因為 JSNumber 不會被 GC,且傳遞相對高效,只需要編解碼地址,所以 JSObject 我們可以設置一個 JSNumber 作為句柄,JS 和 Native 靠這個句柄從緩存中取對象,不用經過 JS 虛擬機
- 如果是一批 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 的數據吞吐量,從而提高幀率。
雖然這樣可以解決單線程的性能瓶頸,但是實際的實現難度非常大,所以放在最后。因為 OpenGL、JavaScriptCore 對多線程非常不友好,要保證它們在多線程環境下沒有問題真的太難了。
尤其要注意雖然JavaScriptCore的接口都是線程安全的,但JSObject不是線程安全的。如果JSObject/JSValue在其他線程使用,要注意延長它們的生命周期,因為在使用時可能會碰到虛擬機GC。
多線程要考慮好實現方案,盡量用最簡單的架構,同時要注意對線程敏感的接口,而且就算設想的很好,也要做好心理準備去面臨成噸的 Bug……
JavaScriptCore 未來會怎樣?
如果蘋果未來依然不更新 JavaScriptCore,不支持 JIT、Wasm,那么 JavaScriptCore 就無法再支持新技術的出現了。
Flutter 給了大家一種新思路,Dart 實現了一種 JIT 結合 AOT 開發的體驗。未來有可能出現支持 TS 的虛擬機,這樣就是大殺器了。
但還是期待蘋果能改進下對JavaScriptCore的支持政策,畢竟系統原生的包增量小,有獨立進程。2020年了,至少先給個JIT?