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?