LiteLoaderBDS-1.16.40/Tools/ScriptX/docs/zh/WebAssembly.md
2023-03-03 10:18:21 -08:00

13 KiB
Raw Blame History

WebAssembly 实现细节及使用须知

[TOC]

WebAssembly的实现不同于V8和JSCore主要体现在于

  1. V8/JSCore是作为JS的Host提供了一套JS的运行环境而WebAssembly则是作为Guest运行在JS环境之内此时JS变成了Host
  2. 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。因为没有足够的理由去做这个操作。

GC

Wasm没有GCJS也没有finalize回调。。。所以就很坑爹

只能手动管理内存。和ScriptX相关的主要包含两个方面

  1. ByteBuffer内存的释放下文详述
  2. 绑定类的内存释放

绑定类的内存释放

在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);

WeakReference

自然也是没法实现的,所以目前所有的script::Weak都是强引用实现。。。

GC 杂谈

PS事实上最新的ChromeV8和FireFox已经实现了WeakRefFinalizationRegistry API但是SafariiOS还没有相关实现而且稍老的V8和FireFox也没有相关实现。所以暂时不考虑他们。

期待WASM的GC proposal赶紧实现。

相关文档:

  1. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry
  2. https://v8.dev/features/weak-references

ByteBuffer

因为主客身份的转换导致WASM的内存模型和其他的引擎都不一样。

为了模拟C++等程序的“堆内存”概念在WASM中会创建一个巨大的ArrayBuffer作为其memory而指针则直接变成了ArrayBuffer的index。另外如果配置了允许内存增长emscrpten中使用配置-sALLOW_MEMORY_GROWTH=1),则有可能会重新创建一个更大的ArrayBuffer并把老的内容copy过来。

ScriptX这这里的实现上做了很多考量最终得出如下结论

  1. 不可以从JS创建的ArrayBuffer获取一个指针并往里读写内容因为从WASM的角度看他们不在当前“进程”的内从空间里这样的话copy不可避免
  2. 可以从WASM分配内存传给JS共享使用避免copy。

对于这样的结论再经过更深入的思考得出了ScriptX ByteBuffer的实现

  1. 为兼容从JS传过来的ArrayBuffer在WASM中开辟一段内存作为copy并提供操作同步两者的内容。这个能力主要为了提供从JS传数据过来的能力兼容性为主性能为辅。
  2. 为了提高性能避免copy。实现接口从ScriptX分配内存传递到JS侧直接使用。这个能力则更关注性能对易用性有一定要求。

内存copy -- 非共享 ByteBuffer

这里是将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 读写内容:

  1. 创建 Local<ByteBuffer> (比如调用 Local<Value>::asByteBuffer())
  2. malloc内存 -- ptr
  3. copy js的 ArrayBuffer 到 ptr
  4. Local<ByteBuffer>::getRawBytes() 直接返回ptr
  5. C++ 读写ptr
  6. C++ 使用 Local<ByteBuffer>::commit 将ptr的内容copy回ArrayBuffer
  7. C++ 使用 Local<ByteBuffer>::syncArrayBuffer的内容copy到ptr
  8. 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的方法:

  1. JS创建ArrayBuffer传递到ScriptX
  2. 使用 ByteBuffer::newByteBuffer(size_t size)
  3. 使用 ByteBuffer::newByteBuffer(void* nativeBuffer, size_t size)

共享 ByteBuffer

这里是将WASM创建的内存传递给JS的情况。可以避免内存copy。

graph TB

    subgraph Wasm
        ByteBuffer
        ByteBuffer -. store .-> WasmMemory
    end

    subgraph JS
        SharedByteBuffer --> ByteBuffer
        SharedByteBuffer -. backing .-> WasmMemory
    end

大致流程:

  1. 创建SharedByteBuffer
  2. 内部malloc内存 -- ptr
  3. 将指针传递给js 作为number类型
  4. js通过new Int8Array(wasm.memory.buffer, ptr, length)创建一个TypedArray读写之

其中wasm.memory.buffer就是WASM“进程”的堆内存这样就做到了内存的共享在JS中也可以直接读写ptr了。

总结创建共享ByteBuffer的方法:

  1. 使用::script::wasm_interop::newSharedByteBuffer(size_t size)
  2. 使用 ByteBuffer::newByteBuffer(std::shared_ptr<void> nativeBuffer, size_t size)
  3. JS使用 new ScriptX.SharedByteBuffer(length) 创建

在js中使用SharedByteBuffer

上述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();

PS判断一个 Local<ByteBuffer> 是不是shared

JS中直接instanceof ScriptX.SharedByteBuffer 即可。 C++中使用 Local<ByteBuffer>::isShared判断之。

当然如果你C++代码把一个SharedByteBuffer 当成 non-shared 用也不会出问题毕竟commit和sync操作是no-op。但是反过来不成立

编译 C++ 到 WASM

这里不做过多介绍关于WASM的知识还请读者自行阅读

  1. https://emscripten.org/
  2. https://webassembly.org/
  3. https://developer.mozilla.org/en-US/docs/WebAssembly
  4. 推荐 https://developer.mozilla.org/en-US/docs/WebAssembly/Understanding_the_text_format

PSMDN的文档非常良心不习惯英文的读者可以选择中文语言。

这里提一句怎么用emscrpten编译cmake工程

  1. 安装emsdk
  2. 按照emsdk教程安装emscripten
  3. cmake工程设置toolchain即可 -DCMAKE_TOOLCHAIN_FILE=<emsdk>/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake

WASM 异常

目前WebAssembly不支持异常emscrpten使用了不太高性能的方法实现了异常。

https://github.com/emscripten-core/emscripten/issues/12475 Exception handling in emscripten: how it works and why its 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 https://github.com/emscripten-core/emscripten/issues/8503

compile&link flag都增加-pthread 如果遇到问题link flag再增加-Wl,--shared-memory,--no-check-features 可选link flag-sPTHREAD_POOL_SIZE=4

MessageQueue

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