Skip to content

Latest commit

 

History

History
366 lines (259 loc) · 12.9 KB

WebAssembly.md

File metadata and controls

366 lines (259 loc) · 12.9 KB

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
Loading

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

Loading

在V8和JSCore的场景下,C++代码作为应用程序的主体,JS则是作为内嵌在应用程序里的一个子环境,我们可以创建多个ScriptEngine来创建多个JS子环境。

但是在WebAssembly的场景下则刚好反了过来。

这个带来的影响就是,在 WASM 下,ScriptEngine 是一个单例(因为外部的JS环境只有一个)。

PS: ScriptEngine@wasm 暂时不支持destroy。因为没有足够的理由去做这个操作。

GC

Wasm没有GC,JS也没有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:事实上最新的Chrome(V8)和FireFox已经实现了WeakRefFinalizationRegistry API,但是Safari(iOS)还没有相关实现;而且稍老的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

Loading

从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

Loading

大致流程:

  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

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

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

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

WASM 异常

目前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

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