[TOC]
WebAssembly的实现不同于V8和JSCore,主要体现在于:
- V8/JSCore是作为JS的Host,提供了一套JS的运行环境,而WebAssembly则是作为Guest,运行在JS环境之内(此时JS变成了Host)
- WebAssembly规范仍在完善中,一些V8/JSCore提供的能力在这里并未实现。
这样的一个从主人到客人的转变带来了这些变化。
举个栗子,假设一个场景,C++需要调用js的draw
方法,js则需要调用C++的log
方法。
V8/JScore:
graph TB
subgraph "Host C++ Application"
subgraph "Guest (JS)"
J[JavaScript Code]
end
A["C++ Code"] --draw--> B[ScriptX]
B --draw--> C[V8/JSCore]
C --draw--> J
J -.log.-> C
C -.log.-> B
B -.log.-> A
end
WebAssembly:
graph TB
subgraph "Host Browser/NodeJS"
subgraph "JS"
J[JavaScript Code]
end
subgraph "Gust (Wasm/C++)"
A["C++ Code"] --draw--> B[ScriptX]
B --draw--> J
J -.log.-> B
B -.log.-> A
end
end
在V8和JSCore的场景下,C++代码作为应用程序的主体,JS则是作为内嵌在应用程序里的一个子环境,我们可以创建多个ScriptEngine
来创建多个JS子环境。
但是在WebAssembly的场景下则刚好反了过来。
这个带来的影响就是,在 WASM 下,ScriptEngine
是一个单例(因为外部的JS环境只有一个)。
PS:
ScriptEngine@wasm
暂时不支持destroy。因为没有足够的理由去做这个操作。
Wasm没有GC,JS也没有finalize回调。。。所以就很坑爹
只能手动管理内存。和ScriptX相关的主要包含两个方面:
- ByteBuffer内存的释放(下文详述)
- 绑定类的内存释放
在V8和JSCore中,依赖引擎提供的finalize回调,实现了绑定类的自动释放,但是在WASM中就搞不定了,使用者只能自己释放之。ScriptX在JS全局提供了辅助方法。
举个栗子:
static ClassDefine<Test> test =
defineClass<Test>("Test")
.constructor()
.build();
EngineScope scope(engine);
engine->registerNativeClass(test);
auto ins = engine->newNativeClass<Test>();
// C++ api to destroy
wasm_interop::destroyScriptClass(ins);
const test = new Test();
// JS API to destroy
ScriptX.destroyScriptClass(test);
自然也是没法实现的,所以目前所有的script::Weak
都是强引用实现。。。
PS:事实上最新的Chrome(V8)和FireFox已经实现了WeakRef
和FinalizationRegistry
API,但是Safari(iOS)还没有相关实现;而且稍老的V8和FireFox也没有相关实现。所以暂时不考虑他们。
期待WASM的GC proposal赶紧实现。
相关文档:
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry
- https://v8.dev/features/weak-references
因为主客身份的转换,导致WASM的内存模型和其他的引擎都不一样。
为了模拟C++等程序的“堆内存”概念,在WASM中会创建一个巨大的ArrayBuffer
作为其memory;而指针则直接变成了ArrayBuffer
的index。另外如果配置了,允许内存增长(emscrpten中使用配置-sALLOW_MEMORY_GROWTH=1
),则有可能会重新创建一个更大的ArrayBuffer
并把老的内容copy过来。
ScriptX这这里的实现上做了很多考量,最终得出如下结论:
- 不可以从JS创建的
ArrayBuffer
获取一个指针,并往里读写内容,因为从WASM的角度看,他们不在当前“进程”的内从空间里,这样的话,copy不可避免! - 可以从WASM分配内存,传给JS共享使用,避免copy。
对于这样的结论,再经过更深入的思考,得出了ScriptX ByteBuffer的实现:
- 为兼容从JS传过来的ArrayBuffer,在WASM中开辟一段内存,作为copy,并提供操作同步两者的内容。这个能力主要为了提供从JS传数据过来的能力,兼容性为主,性能为辅。
- 为了提高性能,避免copy。实现接口从ScriptX分配内存,传递到JS侧直接使用。这个能力则更关注性能,对易用性有一定要求。
这里是将JS创建的ArrayBuffer
, DataView
, TypedArray
传递给ScriptX的情况。
graph TB
subgraph Wasm
ByteBuffer
ByteBuffer -. create tmp store .-> WasmMemory
end
subgraph JS
ArrayBuffer --> ByteBuffer
ArrayBuffer -.-> JSMemory
JSMemory --sync--> WasmMemory
WasmMemory --commit--> JSMemory
end
从JS创建的 ArrayBuffer
读写内容:
- 创建
Local<ByteBuffer>
(比如调用Local<Value>::asByteBuffer()
) - malloc内存 -- ptr
- copy js的
ArrayBuffer
到 ptr Local<ByteBuffer>::getRawBytes()
直接返回ptr- C++ 读写ptr
- C++ 使用
Local<ByteBuffer>::commit
将ptr的内容copy回ArrayBuffer
- C++ 使用
Local<ByteBuffer>::sync
将ArrayBuffer
的内容copy到ptr Local<ByteBuffer>
析构,主动调用commit
并释放ptr
举个栗子:
{
// 底层会创建一个ArrayBuffer
auto b = ByteBuffer::newByteBuffer(16);
// 底层会malloc内存,并copy过来
auto ptr = b.getRawBytes();
read(ptr);
write(ptr);
// 主动copy过去,否则JS的ArrayBuffer看不到新内容
b.commit();
// 当然如果不会在中间过程使用ArrayBuffer
// b析构的时候也会commit过去
}
// 误区:
// 这个用法在WASM下有问题,因为中间变量ByteBuffer已经析构了,所以ptr这时候是个野指针。
auto ptr = value.asByteBuffer().getRawBytes();
// 这个用法就没问题
Function::newFunction([](const Local<ByteBuffer>& buf) {
auto ptr = buf.getRawBytes();
});
auto sharedPtr = value.asByteBuffer().getRawBytesShared();
// 看情况,虽然sharedPtr不是野指针了,但是因为ByteBuffer已经析构
// 理论上这个sharedPtr只能读,写操作不能再commit回ArrayBuffer了
总结创建非共享ByteBuffer
的方法:
- JS创建ArrayBuffer传递到ScriptX
- 使用
ByteBuffer::newByteBuffer(size_t size)
- 使用
ByteBuffer::newByteBuffer(void* nativeBuffer, size_t size)
这里是将WASM创建的内存传递给JS的情况。可以避免内存copy。
graph TB
subgraph Wasm
ByteBuffer
ByteBuffer -. store .-> WasmMemory
end
subgraph JS
SharedByteBuffer --> ByteBuffer
SharedByteBuffer -. backing .-> WasmMemory
end
大致流程:
- 创建SharedByteBuffer
- 内部malloc内存 -- ptr
- 将指针传递给js (作为number类型)
- js通过
new Int8Array(wasm.memory.buffer, ptr, length)
创建一个TypedArray
读写之
其中wasm.memory.buffer
就是WASM“进程”的堆内存,这样就做到了内存的共享,在JS中也可以直接读写ptr了。
总结创建共享ByteBuffer
的方法:
- 使用
::script::wasm_interop::newSharedByteBuffer(size_t size)
- 使用
ByteBuffer::newByteBuffer(std::shared_ptr<void> nativeBuffer, size_t size)
- JS使用
new ScriptX.SharedByteBuffer(length)
创建
上述SharedByteBuffer
在JS中实际上的类型是ScriptX.SharedByteBuffer
的实例。该类比较简单,使用TypeScript风格描述如下:
class SharedByteBuffer {
// 这三个属性和TypedArray保持一致
readonly buffer: ArrayBuffer;
readonly byteOffset: number;
readonly byteLength: number;
// 手动内存管理,销毁该类,并释放WASM的内存
public destroy(): void;
}
注意上面的buffer属性,如上文所述,当WASM grow_memory
的时候,底层的ArrayBuffer
可能会变,因此使用SharedByteBuffer
的时候要即时创建TypedArray
,不要保留引用长期使用(当然你可以配置wasm不grow_memory,或者使用SharedArrayBuffer
,这样buffer属性一定不会变,具体还是取决于你的使用场景)。
最后还是因为WASM 没有GC, JS 没有finalize,因此这段内存需要使用者自己去释放。可以使用上述destroy
方法,也可以使用ScriptX.destroySharedByteBuffer
。C++代码使用wasm_interop::destroySharedByteBuffer
。
举个栗子:
// CPP
Local<Function> drawImage = Function::newFunction([](const Local<ByteBuffer>& buffer) {
void* pixelData = buffer.getRawBytes();
performDraw(pixelData, buffer.size());
});
// JS
const buffer = new ScriptX.SharedByteBuffer(1024);
fillPixelData(new Int8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
drawImage(buffer);
// remember to release
buffer.destroy();
JS中直接instanceof ScriptX.SharedByteBuffer
即可。
C++中使用 Local<ByteBuffer>::isShared
判断之。
当然,如果你C++代码把一个SharedByteBuffer 当成 non-shared 用也不会出问题,毕竟commit和sync操作是no-op。(但是反过来不成立)
这里不做过多介绍,关于WASM的知识还请读者自行阅读
- https://emscripten.org/
- https://webassembly.org/
- https://developer.mozilla.org/en-US/docs/WebAssembly
- 推荐 https://developer.mozilla.org/en-US/docs/WebAssembly/Understanding_the_text_format
PS:MDN的文档非常良心,不习惯英文的读者可以选择中文语言。
这里提一句怎么用emscrpten编译cmake工程
- 安装emsdk
- 按照emsdk教程安装emscripten
- cmake工程设置toolchain即可
-DCMAKE_TOOLCHAIN_FILE=<emsdk>/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake
目前WebAssembly不支持异常,emscrpten使用了不太高性能的方法实现了异常。
emscripten-core/emscripten#12475 Exception handling in emscripten: how it works and why it’s disabled by default
所以emscrpten提供了一个选项-sDISABLE_EXCEPTION_CATCHING=0
来打开异常处理。
另外有一个-sDISABLE_EXCEPTION_CATCHING=2
来针对部分函数打开异常处理。(详见官方文档)
ScriptX的配置是
target_compile_options(ScriptX PRIVATE
-sDISABLE_EXCEPTION_CATCHING=0
)
target_link_options(ScriptX INTERFACE
-sDISABLE_EXCEPTION_CATCHING=0
)
ScriptX 内部所有函数都允许异常处理,顺便把最终产物的link-options设置好了。
使用者还需要针对自己的情况配置是否允许异常处理。
比如在单元测试中也需要异常,所以有这样的配置
if (${SCRIPTX_BACKEND} STREQUAL ${SCRIPTX_BACKEND_WEBASSEMBLY})
target_compile_options(UnitTests PRIVATE
-sDISABLE_EXCEPTION_CATCHING=0
)
endif ()
如果不这么设置,你会发现C++代码明明有catch,结果异常还是被抛到外面去了。
WASM目前有多线程的proposal,而且新的Chrome和FireFox都已经实现了,但是从实现原理上看,线程是用WebWorker来承载的,只不过wasm.memory
是使用SharedArrayBuffer
来做内存共享。
因此在worker线程里,请不要使用ScriptX,因为worker里面和主线程是两个JS环境,而ScriptX在WASM里其实就是HOST JS环境的封装。
在ScriptX里面有代码做检查,EngineScope 检查“只能在创建该ScriptX的线程使用该ScriptX”。
这就导致了在worker线程使用ScriptX其实和主线程的ScriptX环境完全不一样(JS环境不同)。
思考:在多线程场景下是否就可以考虑ScriptX每个worker线程一个实例了呢?
如何在emscrpten中开启worker https://emscripten.org/docs/porting/pthreads.html emscripten-core/emscripten#8503
compile&link flag都增加-pthread
如果遇到问题,link flag再增加-Wl,--shared-memory,--no-check-features
可选,link flag-sPTHREAD_POOL_SIZE=4
Wasm作为JS的guest环境,事实上并不需要自己再实现MessageQueue了,因为JS已经有自己的事件循环了。
这种情况下你的代码仍然可以继续使用MessageQueue,但是请不要调用MessageQueue::loopQueue(LoopType::kLoopAndWait)
因为这个方法会阻塞JS事件循环。
推荐的方式是setInterval
一个定时任务,定时的调用MessageQueue::loopQueue(LoopType::kLoopOnce)
。
engine->set("eventLoop", Function::newFunction([](const Arguments& args) -> Local<Value> {
args.engine()->messageQueue().loopQueue(LoopType::kLoopOnce);
return {};
})
engine->eval("setInterval(eventLoop, 16)");
PS: 因为MessageQueue是线程安全的,你仍然可以在子线程里面postMessage。
written by taylorcyang at 2020-10-16