"wasm 中的 JS 对象" 的填充

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

这里的问题是如何将 JS 对象塞进 wasm 可以使用的 u32 中。目前这种方法的策略是在生成的 foo.js 文件中维护一个模块级变量:一个 heap

"栈" 上的临时 JS 对象

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

JS 对象只能从栈的底部移除。移除只是将 null 存储起来,然后递增一个计数器。由于这种方案的“栈式”性质,它只适用于 wasm 不保留 JS 对象的情况(即它只在 Rust 语法中获得一个“引用”)。

让我们看一个例子。


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

这里我们使用的是 wasm-bindgen 库本身的特殊 JsValue 类型。我们导出的函数 foo 接受对一个对象的引用。这显然意味着它不能在该函数调用生命周期结束后保留该对象。

现在我们实际上想要生成一个看起来像这样的 JS 模块(用 TypeScript 语法):

// 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_variables)]
#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_variables)]
#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_variables)]
#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_variables)]
#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 的 newtype 包装器,即我们从 wasm 传递过来的索引。这里的析构函数是调用 __wbindgen_object_drop_ref 函数来放弃我们对 JS 对象的引用计数的地方,释放我们在上面看到的 slab 中的槽位。

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

在现实中使用 heap

以上解释非常接近今天发生的事情,但实际上有一些区别,尤其是在处理常量值(如 undefinednull 等)方面。请务必查看实际生成的 JS 和生成代码以获取完整详细信息!