WEBKT

WebAssembly 内存陷阱:为什么 JS 传给 Rust 的 Uint8Array 会莫名“失效”?

2 0 0 0

在 WebAssembly(以下简称 Wasm)的混合开发中,JavaScript 与 Rust(或 C++)之间的高效数据交换通常依赖于 线性内存(Linear Memory)

很多开发者在初涉 Wasm 时都会遇到一个极度诡异的 Bug:你通过 wasm-bindgen 将一个 Uint8Array 传递给 Rust,或者从 Rust 获取了一个内存视图,在初期运行得好好的,但当程序运行一段时间,或者处理的数据量变大后,JS 端会突然抛出:

TypeError: Cannot perform %TypedArray%.prototype.length on a detached ArrayBuffer

或者数据虽然还在,但内容变成了全 0。这到底是为什么?本文将带你深挖 Wasm 内存模型,揭开这个“内存消失”之谜。

一、 核心矛盾:线性内存的“动态增长”

Wasm 的内存本质上是一个巨大的、可增长的 ArrayBuffer。在 JS 侧,我们通常通过 wasm.memory.buffer 来访问它。

当你在 Rust 中实例化一个 Vec<u8> 时,Rust 的分配器(如 wee_alloc 或默认的 dlmalloc)会在 Wasm 的堆区寻找空位。如果当前内存不足,Rust 会调用 Wasm 指令 memory.grow

关键点来了:
根据 WebAssembly 标准,当 memory.grow 被调用时,原本的 ArrayBuffer 可能会被销毁,取而代之的是一个新的、容量更大的 ArrayBuffer

二、 为什么 Uint8Array 会失效?

在 JavaScript 中,Uint8Array 只是 ArrayBuffer 的一个视图(View)

当你执行如下操作时:

const bufferView = new Uint8Array(wasm.memory.buffer, offset, length);

这个 bufferView 是紧紧绑定在当前的 wasm.memory.buffer 实例上的。一旦 Wasm 内部触发了内存扩容,原来的 ArrayBuffer 就会进入 Detached(脱离) 状态。

在 V8 等引擎中,一旦 Buffer 被 Detached,旧的视图将无法再读取任何属性(包括 .length),也无法再访问数据。这就是为什么你的 Uint8Array 会莫名其妙“失效”。

三、 复现这个 Bug 的典型场景

假设你有一段 Rust 代码,用于往一个缓存里不断压入数据:

// Rust 代码
static mut DATA_POOL: Vec<u8> = Vec::new();

#[wasm_bindgen]
pub fn add_data(chunk: &[u8]) {
    unsafe {
        DATA_POOL.extend_from_slice(chunk);
    }
}

#[wasm_bindgen]
pub fn get_pool_ptr() *const u8 {
    unsafe { DATA_POOL.as_ptr() }
}

在 JS 端,你可能会这样写:

const ptr = wasm.get_pool_ptr();
const len = 1024;
// 拿到一个视图
const view = new Uint8Array(wasm.memory.buffer, ptr, len);

// 某次调用 add_data 触发了 Vec 的重新分配,导致 Wasm 内存扩容
wasm.add_data(new Uint8Array(1024 * 1024 * 10)); 

console.log(view.length); // 报错!ArrayBuffer 已被 Detached

四、 如何优雅地解决?

既然内存增长不可避免,我们需要在代码逻辑上做兼容。

1. 每次使用前重新创建视图(最直接)

不要长期持有 Uint8Array 的引用。在需要处理 Wasm 内存时,总是重新从 wasm.memory.buffer 获取最新的引用。

function getSafeView(ptr, len) {
    // 永远基于最新的 buffer 创建视图
    return new Uint8Array(wasm.memory.buffer, ptr, len);
}

2. 使用 wasm-bindgen 的闭包处理

如果你使用 wasm-bindgen&[u8] 作为参数,它生成的胶水代码其实已经帮你处理了部分问题。但如果你是将数据异步传回 JS,建议在 JS 端立即 .slice() 拷贝一份数据:

const data = wasm.get_data(); 
// slice 会创建一个独立于 Wasm 内存的新 ArrayBuffer
const secureData = data.slice(); 

虽然这会有一次内存拷贝的开销,但它彻底断开了与 Wasm 线性内存的生命周期绑定。

3. 预留足够大的初始内存

如果你追求极致性能且知道数据的上限,可以在实例化 Wasm 时指定较大的初始内存页数(initial)和最大内存页数(maximum),减少 memory.grow 发生的频率。

# Cargo.toml 中(如果是自定义分配器)
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-O4", "--initial-memory=131072000"] # 128MB

五、 结语

Wasm 的内存失效问题,本质上是 JS 视图的静态性Wasm 堆内存动态性 之间的冲突。

作为一个成熟的 Wasm 开发者,必须养成“内存随时可能扩容”的思维定式。在 JS 侧,除非是极短时间内的同步操作,否则永远不要信任任何持有的 TypedArray 引用。

记住一句话:在下一次 FFI(外部功能接口)调用之后,之前的内存视图可能已经是一片废墟。

码农老余 Rust编程前端性能优化

评论点评