“wasm 中的 JS 对象”的 Polyfill

`wasm-bindgen` 的主要目标之一是允许在 wasm 中使用和传递 JS 对象,但这在今天是不允许的! 虽然确实如此,但这就是 polyfill 的用武之地。

这里的问题是如何将 JS 对象塞入 u32 中供 Wasm 使用。当前此方法的策略是在生成的 foo.js 文件中维护一个模块本地变量:一个 heap

“堆栈”上的临时 JS 对象

foo.jsheap 的第一个槽被视为堆栈。这个堆栈,像典型的程序执行堆栈一样,向下增长。JS 对象被推到堆栈的底部,它们在堆栈中的索引是传递给 wasm 的标识符。维护一个堆栈指针以确定下一个项目被推入的位置。

JS 对象然后也仅从堆栈的底部删除。删除只是存储 null 然后递增计数器。由于此方案的“堆栈式”性质,它仅适用于 Wasm 不持有 JS 对象的情况(也称为它仅在 Rust 中获得“引用”)。

让我们看一个例子。

#![allow(unused)]
fn main() {
// foo.rs
#[wasm_bindgen]
pub fn foo(a: &JsValue) {
    // ...
}
}

在这里,我们使用的是 `wasm-bindgen` 库本身的特殊 `JsValue` 类型。我们导出的函数 `foo` 接受对对象的*引用*。这尤其意味着它不能将对象保留在此函数调用的生命周期之外。

现在我们真正想要生成的是一个看起来像(在 TypeScript 术语中)的 JS 模块

// foo.d.ts
export function foo(a: any);

而我们实际生成的是这样的

// foo.js
import * as wasm from './foo_bg';

const heap = new Array(32);
heap.push(undefined, null, true, false);
let stack_pointer = 32;

function addBorrowedObject(obj) {
  stack_pointer -= 1;
  heap[stack_pointer] = obj;
  return stack_pointer;
}

export function foo(arg0) {
  const idx0 = addBorrowedObject(arg0);
  try {
    wasm.foo(idx0);
  } finally {
    heap[stack_pointer++] = undefined;
  }
}

在这里我们可以看到一些值得注意的操作点

  • Wasm 文件被重命名为 `foo_bg.wasm`,我们可以看到这里生成的 JS 模块是如何从 Wasm 文件导入的。
  • 接下来我们可以看到我们的 `heap` 模块变量,它用于存储所有可从 wasm 引用的 JS 值。
  • 我们导出的函数 `foo` 接受一个任意参数 `arg0`,该参数通过 `addBorrowedObject` 对象函数转换为索引。然后将该索引传递给 Wasm,以便 Wasm 可以使用它进行操作。
  • 最后,我们有一个 `finally`,它会释放堆栈槽,因为它不再使用,从而弹出在函数开始时推送的值。

深入了解 Rust 方面的内容以了解那里发生了什么也很有帮助!让我们看一下 `#[wasm_bindgen]` 在 Rust 中生成的代码

#![allow(unused)]
fn main() {
// what the user wrote
pub fn foo(a: &JsValue) {
    // ...
}

#[export_name = "foo"]
pub extern "C" fn __wasm_bindgen_generated_foo(arg0: u32) {
    let arg0 = unsafe {
        ManuallyDrop::new(JsValue::__from_idx(arg0))
    };
    let arg0 = &*arg0;
    foo(arg0);
}
}

与 JS 一样,这里的重点是

  • 原始函数 `foo` 在输出中未修改
  • 这里生成的函数(具有唯一名称)是从 Wasm 模块实际导出的函数
  • 我们生成的函数接受一个整数参数(我们的索引),然后将其包装在 `JsValue` 中。 这里有一些小技巧,暂时不值得深入研究,但我们稍后会看到幕后发生的事情。

长期存在的 JS 对象

上述策略在 JS 对象仅在 Rust 中临时使用时非常有用,例如仅在一次函数调用期间。然而,有时,对象可能具有动态生命周期或需要存储在 Rust 的堆上。为了处理这种情况,JS 对象的管理还有第二部分,自然地对应于 JS heap 数组的另一侧。

传递给 Wasm 的非引用的 JS 对象被认为在 Wasm 模块内部具有动态生命周期。因此,严格的堆栈 push/pop 将不起作用,我们需要为 JS 对象提供更持久的存储。为了解决这个问题,我们构建了自己的“slab 分配器”。

一图胜千言,让我们用一个例子来展示会发生什么。

#![allow(unused)]
fn main() {
// foo.rs
#[wasm_bindgen]
pub fn foo(a: JsValue) {
    // ...
}
}

请注意,之前的 JsValue 前面缺少了 &,在 Rust 中这意味着它获得了 JS 值的所有权。导出的 ES 模块接口与之前相同,但所有权机制略有不同。让我们看看生成的 JS 的 slab 如何运作

import * as wasm from './foo_bg'; // imports from Wasm file

const heap = new Array(32);
heap.push(undefined, null, true, false);
let heap_next = 36;

function addHeapObject(obj) {
  if (heap_next === heap.length)
    heap.push(heap.length + 1);
  const idx = heap_next;
  heap_next = heap[idx];
  heap[idx] = obj;
  return idx;
}

export function foo(arg0) {
  const idx0 = addHeapObject(arg0);
  wasm.foo(idx0);
}

export function __wbindgen_object_drop_ref(idx) {
  heap[idx ] = heap_next;
  heap_next = idx;
}

与之前不同,我们现在在 foo 的参数上调用 addHeapObject,而不是 addBorrowedObject。此函数将使用 heapheap_next 作为 slab 分配器,获取一个槽来存储对象,并在找到后将一个结构放在那里。请注意,这发生在数组的右半部分,而不是位于左半部分的堆栈。这种规律大致反映了普通程序中的堆栈/堆。

这个生成的模块的另一个值得关注的方面是 __wbindgen_object_drop_ref 函数。这是一个实际导入到 wasm 而不是在此模块中使用的函数!此函数用于标记 Rust 中 JsValue 生命周期结束,或者换句话说,当它超出范围时。除此之外,这个函数基本上只是一个通用的 “slab 释放” 实现。

最后,让我们再次看一下生成的 Rust 代码

#![allow(unused)]
fn main() {
// what the user wrote
pub fn foo(a: JsValue) {
    // ...
}

#[export_name = "foo"]
pub extern "C" fn __wasm_bindgen_generated_foo(arg0: u32) {
    let arg0 = unsafe {
        JsValue::__from_idx(arg0)
    };
    foo(arg0);
}
}

啊,这看起来熟悉多了!这里没有发生太多有趣的事情,所以让我们继续...

JsValue 的解剖

目前,JsValue 结构在 Rust 中实际上非常简单,它是

#![allow(unused)]
fn main() {
pub struct JsValue {
    idx: u32,
}

// "private" constructors

impl Drop for JsValue {
    fn drop(&mut self) {
        unsafe {
            __wbindgen_object_drop_ref(self.idx);
        }
    }
}
}

或者换句话说,它是 u32 的新类型包装器,它是我们从 wasm 传递过来的索引。析构函数在这里调用 __wbindgen_object_drop_ref 函数,以放弃我们对 JS 对象的引用计数,从而释放我们在上面看到的 slab 中的槽。

如果你还记得,当我们在上面采用 &JsValue 时,我们在本地绑定周围生成了一个 ManuallyDrop 的包装器,这是因为我们想避免在对象来自堆栈时调用此析构函数。

实际上使用 heap

上面的解释与今天发生的情况非常接近,但实际上存在一些差异,尤其是在处理诸如 undefinednull 等常量值时。请务必查看实际生成的 JS 和生成代码以获取完整详细信息!