Rust + WebAssembly 全链路解析

第11章 内存管理与 Guest-Host 通信开销

作者 杨艺韬 · 19,552 字 · 发布于 · 更新于

"Rule 1: Don't do it. Rule 2 (for experts only): Don't do it yet." — M. A. Jackson(优化两原则)

11.1 内存:Guest 与 Host 的唯一共享边界

WASM 模块(Guest)和 JavaScript(Host)之间没有共享的对象模型、没有共享的类型系统、没有共享的调用约定——它们唯一的共享数据就是线性内存(Linear Memory)。

线性内存是一段连续的可增长字节序列,由 ArrayBuffer 在 JS 侧实现。Rust 的所有堆数据(Vec、String、Box)和栈数据都在这块内存中。JS 无法直接操作 WASM 的栈(栈指针是 WASM 的内部实现细节),但可以读写线性内存的任何地址——前提是知道地址。

graph TB
  subgraph "JavaScript (Host)"
    A["JS 对象<br/>GC 管理"] --- B["TypedArray 视图<br/>Uint8Array / Float32Array"]
  end
  subgraph "WASM 线性内存 (唯一共享边界)"
    C["栈区<br/>__stack_pointer →"] --- D["堆区<br/>dlmalloc 管理"]
    D --- E["空闲区<br/>等待 memory.grow"]
  end
  B -.->|"直接读写"| C
  B -.->|"直接读写"| D
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333
  style E fill:#d1d5db,color:#333

这个设计的关键含义:

  1. 所有数据传递最终都是内存操作——无论是通过 wasm-bindgen 的类型转换还是手动指针操作,数据必须写入线性内存或从线性内存读出
  2. JS 和 WASM 都能读写同一块内存——默认情况下两者跑在同一个线程上、轮流执行,不存在数据竞争;只有用 Web Worker + 共享内存(SharedArrayBuffer)时才需要同步机制
  3. 线性内存的增长是不可逆的——memory.grow 申请的页永远不会归还给浏览器。线性内存只增不减

这种"内存是唯一共享边界"的设计并非偶然——它是 WASM 安全模型的核心。WASM 模块被设计为一个能力受限的沙箱:它无法访问宿主的文件系统、网络或 DOM,唯一的交互方式就是通过明确声明的导入/导出函数和共享的线性内存。这意味着所有跨边界的通信,无论多复杂,最终都归结为"向某块内存写入字节,然后通知对方读取"。理解这个底层模型,才能在更高层的抽象(如 wasm-bindgen 的类型转换)出现性能问题时,回退到最原始的内存操作来获取最佳性能。

11.2 WASM 内存管理的特殊性

WASM 的内存管理面临一个原生平台不存在的约束:分配器只能通过 memory.grow 获取内存——而 memory.grow 只能申请整页(64KB),不能申请任意大小的块。

这意味着 Rust 的全局分配器必须自己实现"大页切小块"的逻辑——在 64KB 的页内切分出用户请求的 8 字节、64 字节、1KB 等小块。

flowchart TD
  A["Rust: Vec::push()"] --> B["alloc::alloc(layout)"]
  B --> C{"dlmalloc 有空闲块?"}
  C -->|是| D["从空闲链表分配"]
  C -->|否| E["memory.grow(所需页数)"]
  E --> F{"增长成功?"}
  F -->|是| D
  F -->|否| G["alloc::alloc_error → abort"]
  D --> H["返回指针"]
  H --> A
  style E fill:#f59e0b,color:#333
  style G fill:#dc2626,color:#fff

11.2.1 dlmalloc 在 WASM 中的实现

dlmalloc 是 Rust 在 wasm32-unknown-unknown 上的默认分配器——rustc book 的 src/doc/rustc/src/platform-support/wasm32-unknown-unknown.md:58 一句话写明:"This target uses the dlmalloc crate as the default global allocator." 它的核心数据结构:

  1. 空闲链表(free list):按大小分桶(bin),每个桶是一个双向链表,链接大小相近的空闲块
  2. 小块缓存(smallbin):小请求按固定间距分桶,命中对应桶就是摘链表头——常数时间
  3. 大块分配(treebin):大请求走一棵按块大小组织的位数字树(trie),做近似 best-fit——查找是对数级而不是线性扫描整条空闲链
  4. 顶部块(top chunk):线性内存末尾的大块空闲区域——当所有桶都不满足时从顶部块切分

dlmalloc 在 WASM 中的一个关键差异:没有 mmap/brk 系统调用。原生平台上,dlmalloc 对超过阈值的大块请求直接走 mmap,堆本身则靠 brk/sbrk 扩展。WASM 中这两个都不存在——唯一的方式是 memory.grow。

memory.grow 的代价分两种情形:引擎通常为 32 位线性内存预留了一大段虚拟地址空间(Wasmtime 的 Config::memory_reservation 在 64 位宿主上默认就是 4GiB,32 位宿主上只有 10MiB,见 crates/wasmtime/src/config.rs:1768-:1779),在预留区内增长只需要把新的页提交为可写,不复制数据;只有超出预留、需要重新分配整块内存时,才会出现"分配新缓冲区 + 复制旧内容"的昂贵路径。

但对 JS 侧还有一个必然的副作用:非共享的线性内存增长后,旧的 ArrayBuffer 会被 detach,所有指向它的 TypedArray 视图立即失效(§11.5.3)。

这对分配器设计的影响:减少 memory.grow 调用次数比优化单次分配速度更重要——既是为了避开可能的重分配,也是为了不让 JS 侧的视图反复失效。但 dlmalloc 自己并不会"多申请":Rust 1.96.0 锁定的 dlmalloc 0.2.11 在 sys_alloc 里只把请求(加上少量头尾开销)向上取整到 64KiB 的整数倍(DEFAULT_GRANULARITY),再按这个页数调一次 memory.grow,不额外预留。想减少增长次数,得在应用层自己预分配(比如启动时就把大缓冲区一次分配到位,见 §11.6.1)。

11.2.2 替代分配器

分配器 体积开销 分配速度 特点 适用场景
dlmalloc 几者中最大(第 9 章实测:最小 cdylib 里分配相关共约 11.8KB,dlmalloc 自身函数约 7.1KB) 快 功能完整,Rust 在 wasm32 上的默认选择 通用
wee_alloc 明显更小 慢 已停止维护,且有已知的内存不归还问题 不建议新项目采用
talc 小(第 9 章实测 1,536 字节) 未实测 为 wasm/嵌入式设计,仍在维护 体积敏感的通用替代
lol_alloc 更小(第 9 章实测 849 字节) 视具体分配器而定 提供一组可组合的分配器(FreeListAllocator、AssumeSingleThreaded 等),也提供纯泄漏式的极简实现 体积敏感、能接受手工组装
不用堆 0 N/A #![no_std] + 静态缓冲区 纯计算、完全不需要动态分配

选择分配器时需要权衡三个维度:体积、分配速度、碎片化程度。这里要特别提醒 wee_alloc——它在很长一段时间里是"WASM 减体积"教程里的标配,但项目已经归档不再维护,且存在释放后不归还给分配器的已知缺陷。今天更稳妥的路线是:先用默认的 dlmalloc,确认分配器体积真的是瓶颈之后,再评估 talc、lol_alloc 这类替代方案,或者干脆改造成不依赖堆的静态缓冲区设计。

11.3 数据传递策略:copy vs pointer vs shared buffer

Rust(WASM)和 JavaScript 之间的数据传递有三种基本策略,按性能从低到高排列。

11.3.1 策略一:值复制(wasm-bindgen 默认行为)

wasm-bindgen 的类型转换在每次跨边界调用时复制数据。这是最安全的策略——Rust 和 JS 各自拥有数据副本,不需要同步——但复制开销最大。

// wasm-bindgen 默认行为:字符串被复制到 WASM 内存
#[wasm_bindgen]
pub fn greet(name: String) -> String {
    format!("Hello, {}!", name)
}

调用 greet("world") 时发生的事情:

  1. 胶水函数 passStringToWasm0(arg, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc) 先按字符串长度调用 __wbindgen_malloc(len, 1)(注意签名是 (size, align) 两个参数)
  2. 然后走 ASCII 快路径:逐字符检查 charCodeAt(offset) <= 0x7F,直接把码点当字节写进线性内存——纯 ASCII 字符串根本不会调用 TextEncoder
  3. 一旦遇到非 ASCII 字符,才 __wbindgen_realloc 扩容到 offset + 剩余长度 * 3(UTF-8 最坏膨胀率),用 TextEncoder.encodeInto 就地编码,最后再 realloc 收缩到实际长度
  4. Rust 的 String 从该内存地址构造
  5. Rust 的 format! 生成 "Hello, world!"(13 字节),分配新的 WASM 内存
  6. 返回时 getStringFromWasm0(ptr, len) 用模块级缓存的 TextDecoder 解码线性内存的 subarray
  7. 两块内存分别由不同的一方释放(下面详述)

第 1-3 步的模板出自 crates/cli-support/src/js/mod.rs:2130 的 expose_pass_string_to_wasm(),函数体拼在同文件 2198 行的 function {ret}(arg, malloc, realloc)——ASCII 快路径、realloc(ptr, len, len = offset + arg.length * 3, 1) 扩到最坏膨胀率、encodeInto 续写、再 realloc(ptr, len, offset, 1) 收缩,顺序与上面一致。整体生成结果可以在 wasm-bindgen 仓库的 crates/cli/tests/reference/*.bg.js 里逐行对照——那是 CLI 生成胶水代码的黄金样本。

⚠️ 第 7 步是高频误解,值得单独说清:JS 胶水只对出向(返回)值生成延迟释放。CLI 里发出 Instruction::DeferFree 的地方只有两处,都在 crates/cli-support/src/wit/outgoing.rs(:125 和 :530);入向参数没有对应指令,所以生成的 JS 里根本找不到对参数指针的 __wbindgen_free(你在 finally 块里看到的 deferred*_0/deferred*_1 是返回值的 (ptr, len))。参数那块线性内存是 Rust 侧在 shim 返回时还回去的:本例 name: String 由 Rust 接管所有权、函数结束时 drop;若签名写成 &str,则走 RefFromWasmAbi,而 impl RefFromWasmAbi for str 的 Anchor 是 Box<str>(src/convert/slices.rs:381),shim 里的局部锚点析构时释放。结论一样——函数一返回,参数内存就没了,Rust 侧不能把 &str 存进全局状态。

这个过程涉及最多 2 次编码转换、2 次内存分配、2 次内存复制、2 次内存释放。ASCII 输入能省掉入方向的编码转换,但出方向的 TextDecoder.decode 无论如何省不掉——JS string 是引擎自己的堆对象。

按开销从低到高排列(实测量级见 §10.5:数值签名约 5 ns,按值传 JsValue 约 37 ns,16 字节 &str 约 66 ns 且随长度线性增长;具体数值随引擎和数据长度浮动):

数据类型 传递方向 开销构成
i32 / bool / f64 双向 仅调用约定转换,最低
struct 句柄(#[wasm_bindgen] 类型) 双向 只传一个 i32 指针,与标量同档
JsValue 双向 一次 externref 表的 alloc/dealloc(见 §11.4.1)
&[u8] / Vec<u8> 双向 一次 malloc + 一次 TypedArray.set 复制 + 一次 free,与长度成正比
&str / String 双向 上面全部,外加编码/解码;入方向纯 ASCII 可省,出方向省不掉

wasm-bindgen 在生成的胶水里把 TextEncoder 和 TextDecoder 都提升为模块级单例(new TextEncoder()、new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }),见 crates/cli-support/src/js/mod.rs:2434;ignoreBOM 旁边还留了一行注释说明理由——"ignoreBOM is needed so that the BOM will be preserved when sending a string from Rust to JS",:2424),所以不存在"每次调用重建编码器"的开销。有意思的是,面向浏览器的 bundler / web 目标里,解码器旁边还有一个 Safari 相关的兜底——累计解码字节数超过 MAX_SAFARI_DECODE_BYTES 时会重建 TextDecoder,绕开 Safari 的一个长期缺陷;这个常量在 crates/cli-support/src/js/mod.rs:2468 写作 0x80000000 - 0x100000,即 2146435072。如果你绕过 wasm-bindgen 手写胶水,这两点都要自己复刻。

另一个影响复制开销的因素是 JS 引擎的 GC 压力。每次 String 从 WASM 传递到 JS 时,TextDecoder 创建一个新的 JS string——这个 string 分配在 JS 堆上,由 GC 管理。如果跨边界调用频率很高(如每帧传递数百个字符串),GC 压力会逐渐累积,最终触发 GC 暂停。这种暂停是不可预测的,但在基准测试中如果只测量单次调用的延迟,很容易忽略这个累积效应。

11.3.2 策略二:指针传递

避免复制,只传指针——JS 直接操作 WASM 的线性内存。

#[wasm_bindgen]
pub fn process_at(ptr: *const u8, len: usize) -> u32 {
    let data = unsafe { slice::from_raw_parts(ptr, len) };
    // 处理 data...
    result_ptr as u32  // 返回结果的指针
}

JS 侧:

// 预分配 WASM 内存
const inputPtr = wasm.__wbindgen_malloc(inputData.length, 1);  // 签名是 (size, align)
new Uint8Array(wasm.memory.buffer).set(inputData, inputPtr);

const resultPtr = wasm.process_at(inputPtr, inputData.length);
const resultLen = wasm.get_result_len();
// 先用完或复制出结果,再释放——free 之后这段视图指向的就是可能被重用的内存
const result = new Uint8Array(wasm.memory.buffer, resultPtr, resultLen).slice();

// 清理
wasm.__wbindgen_free(inputPtr, inputData.length, 1);  // 签名是 (ptr, size, align)
// 前提:Rust 侧的结果缓冲区正好是 resultLen 字节、按 1 字节对齐分配(例如 capacity == len 的 Vec<u8>)
wasm.__wbindgen_free(resultPtr, resultLen, 1);

这种方式完全避免了字符串的编码转换——JS 直接把字节写入 WASM 线性内存,Rust 直接从线性内存读取。开销只剩一次函数调用加上 Uint8Array.set() 的复制时间。

适合二进制数据(图像像素、加密报文、Protobuf),不适合需要 UTF-16↔UTF-8 转换的字符串。对于字符串场景,TextEncoder 仍然需要做编码转换——但只做一次(JS→WASM),而不是两次。

指针传递的风险:

  1. 悬垂指针:如果 WASM 侧在 JS 还在使用指针时释放了内存,JS 读到的是已被重分配的垃圾数据。必须在 JS 侧确保不再使用指针后才调用 free。
  2. memory.grow 后视图失效:如果 WASM 侧的任何操作触发了 memory.grow,之前创建的 Uint8Array 视图会失效(因为底层的 ArrayBuffer 被替换了)。必须在 memory.grow 后重新获取视图。
  3. 类型安全:*const u8 丢失了类型信息——Rust 侧用 unsafe 代码从原始指针重构数据,编译器无法验证正确性。

11.3.3 策略三:共享缓冲区

如果 JS 和 WASM 需要反复交换同一块数据,预分配一个共享缓冲区——只分配一次内存,后续操作零分配。

#[wasm_bindgen]
pub struct SharedBuffer {
    data: Vec<u8>,
}

#[wasm_bindgen]
impl SharedBuffer {
    #[wasm_bindgen(constructor)]  // 没有它,JS 侧只能写 SharedBuffer.new(...)
    pub fn new(size: usize) -> SharedBuffer {
        SharedBuffer { data: vec![0; size] }
    }

    pub fn ptr(&self) -> *const u8 {
        self.data.as_ptr()
    }

    pub fn len(&self) -> usize {
        self.data.len()
    }

    pub fn process(&mut self, input_len: usize) -> usize {
        // 从 self.data[0..input_len] 读取输入
        // 写结果到 self.data[0..output_len]
        // 返回 output_len
        output_len
    }
}

JS 侧:

const buf = new SharedBuffer(1024 * 1024); // 1MB 共享缓冲区
const view = new Uint8Array(wasm.memory.buffer, buf.ptr(), buf.len());

// 写入数据
view.set(inputData, 0);
const outputLen = buf.process(inputData.length);
const output = view.slice(0, outputLen);

这种方式只分配一次内存,后续操作零分配——最佳性能。风险是 JS 侧的 Uint8Array 视图在 memory.grow 后失效——需要在每次 process 后重新获取视图。

memory.grow 后重建视图的方案:

let view = null;
function getView() {
    // 每次获取视图时检查 buffer 是否变化
    if (!view || view.buffer !== wasm.memory.buffer) {
        view = new Uint8Array(wasm.memory.buffer, buf.ptr(), buf.len());
    }
    return view;
}

关于本章的数字

本章的毫秒数都在同一环境下实测:Rust 1.96.0 --release、wasm-bindgen 0.2.118 CLI 生成的胶水、node v24.20.0,Intel Xeon Gold 6240(KVM 虚拟机,与第 10 章同一台机器);每项预热 1 次后取多次运行的中位数,连跑三轮。这是 node 里的 V8,不是浏览器,也不是公开基准——设备、引擎版本、数据规模都会让结果整体平移,请只看相对关系。没法在这个环境里测的(Canvas/WebGL、跨格式序列化等),正文只给定性描述。

11.3.4 三种策略的性能对比

用 §11.8 的 4K 图像(3840×2160 RGBA,约 33MB)灰度转换实测,数字包含把输入从 JS 数组写进线性内存的那次复制:

策略 4K 图像处理耗时 每次调用的内存分配 适用场景
值复制(&[u8] 进、Vec<u8> 出) ≈ 53 ms 入参一次 malloc/free + 返回值一次 malloc/free 简单 API、一次性调用
指针传递(JS 自己 malloc + set) ≈ 53 ms 输入、输出各一次 malloc/free 二进制数据、单次大批量
共享缓冲区(预分配,只 set 输入) ≈ 52 ms 0(缓冲区预分配) 高频重复调用、实时处理

三种策略几乎打平——因为在这个负载里,灰度计算本身就占了约 47 ms,33MB 的 TypedArray.set 只要约 4.5 ms,一对 malloc/free 更是可以忽略。省掉复制和分配的收益,只有在每次调用的计算量相对数据量很小时(比如只是扫一遍求和、或者每帧只改一小块)才会显现出来;在每帧都要处理的场景(视频帧、实时音频)里,共享缓冲区的价值更多在于不产生分配、不触发 memory.grow,而不在于这一次的耗时。先测量,再决定要不要为它多写一套缓冲区生命周期管理。

11.4 wasm-bindgen 的内存管理机制

理解 wasm-bindgen 如何管理内存有助于避免常见的泄漏陷阱。

11.4.1 externref 表:JsValue 到底存在哪

关于这一点流传着一个过时的说法:"wasm-bindgen 在 JS 侧维护一个对象栈数组"。早期版本确实如此(heap 数组 + addHeapObject/takeObject),但 0.2.118 生成的胶水里已经完全找不到这些名字了——现在 JsValue 存放在一张 WebAssembly externref 表里,表本身由 WASM 模块导出,名字叫 __wbindgen_externrefs:

// wasm-bindgen 0.2.118 生成的胶水(crates/cli/tests/reference/async-void.bg.js)
export function __wbindgen_init_externref_table() {
    const table = wasm.__wbindgen_externrefs;
    const offset = table.grow(4);
    table.set(0, undefined);
    table.set(offset + 0, undefined);
    table.set(offset + 1, null);
    table.set(offset + 2, true);
    table.set(offset + 3, false);
}

function addToExternrefTable0(obj) {
    const idx = wasm.__externref_table_alloc();   // 槽位分配在 WASM 侧
    wasm.__wbindgen_externrefs.set(idx, obj);
    return idx;
}

function takeFromExternrefTable0(idx) {
    const value = wasm.__wbindgen_externrefs.get(idx);
    wasm.__externref_table_dealloc(idx);
    return value;
}

几个值得注意的细节:

  • 表的前几个槽位是保留的常量——undefined / null / true / false 各占一格,这几个高频值不需要每次分配槽位
  • 槽位的分配与回收由 WASM 侧的 __externref_table_alloc / __externref_table_dealloc 管理,Rust 侧 JsValue 的 Drop 会调到 dealloc
  • 引擎能看见这些引用:存在 externref 表里的 JS 对象对 GC 是可达的,不再需要 JS 侧的数组来"钉住"它们

后果没变:Rust 侧的 JsValue 如果泄漏(例如被 mem::forget 或存进了永不清理的全局结构),对应的表槽位就永远不会归还,被引用的 JS 对象也就永远不会被回收。

这条路径由 wasm-bindgen 的 externref transform pass 生成(crates/cli-support/src/transforms/externref/mod.rs,表名在 :99 被设成 "__wbindgen_externrefs",:170 认 "__externref_table_alloc" 这个导出;JS 侧的 __wbindgen_init_externref_table 是 crates/cli-support/src/intrinsic.rs:88 登记的 intrinsic)——它在模块已经带 reference-types target feature 时自动启用(crates/cli-support/src/lib.rs:344-:347),而现代 rustc 的 wasm32 目标默认就带这个 feature。

11.4.2 wasm-bindgen 如何传递 Vec<u8> 和 String

这里也有一个常见误解,值得用生成代码正面回答:Vec<u8> 返回给 JS 是复制的,而且释放是确定性的,不依赖 GC。看 wasm-bindgen 0.2.118 为 fn echo_vec_u8(a: Vec<u8>) -> Vec<u8> 生成的胶水:

export function echo_vec_u8(a) {
    const ptr0 = passArray8ToWasm0(a, wasm.__wbindgen_malloc);
    const len0 = WASM_VECTOR_LEN;
    const ret = wasm.echo_vec_u8(ptr0, len0);
    var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();  // ← 复制
    wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);            // ← 立即释放
    return v2;
}

function getArrayU8FromWasm0(ptr, len) {
    ptr = ptr >>> 0;
    return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}

流程是:

  1. Rust 的 Vec<u8> 通过多值返回把(指针,长度)交给 JS
  2. getArrayU8FromWasm0 先在线性内存上开一个 subarray 视图——这一步确实零拷贝
  3. 紧接着的 .slice() 把它复制成一个独立的 Uint8Array——这一步是复制
  4. __wbindgen_free(ptr, len * 1, 1) 立刻释放 WASM 那侧的内存,同步、确定、不等 GC

为什么不能省掉 .slice()?因为返回一个活的线性内存视图是不安全的:下一次 memory.grow 就会把它 detach,而调用方拿到的对象会毫无征兆地变成空的。wasm-bindgen 选择了正确性优先。想要零拷贝,必须自己走 §11.3.2 / §11.3.3 的指针或共享缓冲区路线,并自己承担视图失效的管理责任。

FinalizationRegistry 在生成的胶水里确实出现了,但用途是回收闭包(CLOSURE_DTORS,回收 Closure 的 WASM 侧状态)和导出结构体的 JS 包装对象(每个类一个 XxxFinalization,见 §11.4.3),不是回收 Vec。

对于 String,流程类似但多了一次 TextDecoder.decode——UTF-8 字节到 JS string 的转换本身就是复制,无法零拷贝。

11.4.3 #[wasm_bindgen] 结构体的内存模型

#[wasm_bindgen] 标注的 Rust 结构体在 JS 侧表现为一个包装类(wrapper class)——JS 持有 WASM 内存中结构体的指针,通过包装类的方法调用 WASM 函数。

#[wasm_bindgen]
pub struct ImageProcessor {
    width: u32,
    height: u32,
    buffer: Vec<u8>,
}

#[wasm_bindgen]
impl ImageProcessor {
    #[wasm_bindgen(constructor)]
    pub fn new(width: u32, height: u32) -> ImageProcessor {
        ImageProcessor {
            width,
            height,
            buffer: vec![0; (width * height * 4) as usize],
        }
    }

    pub fn process(&mut self) {
        // 修改 self.buffer...
    }
}

JS 侧:

const proc = new ImageProcessor(1920, 1080);  // 在 WASM 内存中分配
proc.process();                                 // 修改 WASM 内存中的 buffer
proc.free();                                    // 释放 WASM 内存

应当主动调用 .free():free() 会立即 drop Rust 侧的 ImageProcessor、释放它的 WASM 内存。0.2.118 的胶水给每个导出类都生成了一个 FinalizationRegistry 兜底(构造时 ImageProcessorFinalization.register(this, this.__wbg_ptr, this),回调里调 __wbg_imageprocessor_free,宿主不支持 FinalizationRegistry 时退化为空操作),所以忘了 free() 并不一定永久泄漏——但回调什么时候跑、跑不跑都由 GC 决定,没有任何保证。像这里一个 8MB 缓冲区的对象,指望 GC 兜底,线性内存可能早已被撑大(而线性内存只增不减)。

一个常见的"泄漏"模式(至少是把释放时机交给了不可预测的 GC):

function processData(data) {
    const proc = new ImageProcessor(1920, 1080);
    proc.process();
    return proc.getResult();
    // proc 没有 free()!WASM 内存要等 GC 兜底,时机不可控
}

// 修复
function processData(data) {
    const proc = new ImageProcessor(1920, 1080);
    try {
        proc.process();
        return proc.getResult();
    } finally {
        proc.free();  // 确保释放
    }
}

11.5 直接内存访问:JS 操作 WASM 内存

除了通过 wasm-bindgen 的 API,JS 可以直接通过 wasm.memory.buffer 访问 WASM 的整个线性内存。这是零拷贝通信的基础。

11.5.1 创建 TypedArray 视图

const wasm = await init();

// 获取 WASM 线性内存的 ArrayBuffer
const memory = wasm.memory.buffer;

// 创建不同类型的视图——都指向同一块内存
const u8 = new Uint8Array(memory);     // 按字节
const u32 = new Uint32Array(memory);   // 按 i32
const f32 = new Float32Array(memory);  // 按 f32
const f64 = new Float64Array(memory);  // 按 f64

// 读取 WASM 地址 0x1000 处的 i32
const value = u32[0x1000 / 4];  // 注意偏移量要除以元素大小

// 写入
u32[0x1000 / 4] = 42;

这些视图共享同一块 ArrayBuffer——修改一个视图的数据,其他视图也会看到变化(因为它们指向同一块物理内存)。这可以用来做类型重新解释(type punning),但要注意字节序(WASM 是小端序,和 x86-64 一致)。

11.5.2 偏移量和对齐

WASM 的线性内存是按字节编址的,但 TypedArray 的索引是按元素计算的。一个常见的错误是混用字节偏移和元素索引:

// 正确:用字节偏移创建视图
const offset = 0x2000;  // WASM 地址
const view = new Float32Array(memory, offset, 256);  // 256 个 f32 = 1KB

// 正确:在全局视图中用元素索引
const globalView = new Float32Array(memory);
const value = globalView[0x2000 / 4];  // 字节偏移 / 元素大小

// 错误:直接用字节偏移作为元素索引
const wrong = globalView[0x2000];  // 这会读到偏移 0x2000 * 4 = 0x8000 处的数据

关于对齐,有一点必须澄清:WASM 的普通内存指令允许非对齐访问,不会因为未对齐而 trap。i32.load 的 align 立即数只是一个提示(文本格式写 align=4,二进制里存的是以 2 为底的指数 2,意思是"我保证这里是 4 字节对齐的"),提示不准确时引擎必须仍然给出正确结果,只是可能慢一点。例外是线程提案的原子指令:i32.atomic.load 之类必须自然对齐,否则直接 trap(Wasmtime 44 实跑报 wasm trap: unaligned atomic)——§11.7 用共享内存 + Atomics 时要记住这一点。真正会因为对齐抛错的是 JS 这一侧:new Float64Array(buffer, byteOffset, len) 要求 byteOffset 是 8 的倍数,否则抛 RangeError。所以设计跨边界的数据布局时,对齐(除原子操作外)是为了 JS 的 TypedArray 构造器和 CPU 的访问效率,不是为了避免 WASM trap。

11.5.3 memory.grow 后重建视图

非共享线性内存执行 memory.grow 后,旧的 ArrayBuffer 被 detach,wasm.memory.buffer 换成一个新对象——所有旧的 TypedArray 视图随之失效:byteLength 变成 0,索引读返回 undefined,索引写被静默丢弃(slice() 之类的方法则直接抛 TypeError)。

wasm-bindgen 自己就是这么防御的——它把每种视图缓存成模块级变量,每次取用前检查是否已被 detach:

// wasm-bindgen 0.2.118 生成的胶水
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
    if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
        cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
    }
    return cachedUint8ArrayMemory0;
}

注意判据是 byteLength === 0——detach 之后长度归零,这比比较 view.buffer !== wasm.memory.buffer 更省一次属性访问。自己写胶水时照抄这个模式即可。唯一的例外是共享内存:shared 的线性内存增长时不会 detach 旧的 SharedArrayBuffer,旧视图仍然有效、只是看不到新增的部分,所以 CLI 对共享内存改用 cached.buffer !== wasm.memory.buffer 作判据(crates/cli-support/src/js/mod.rs:2849 起的三条分支)。

let view = new Uint8Array(wasm.memory.buffer);

function safeRead(offset, len) {
    if (view.buffer !== wasm.memory.buffer) {
        // buffer 变了——重建视图
        view = new Uint8Array(wasm.memory.buffer);
    }
    return view.slice(offset, offset + len);
}

对于共享缓冲区模式(11.3.3),这个问题更关键——因为 process() 可能触发 memory.grow,导致 JS 侧的视图失效。最安全的做法是每次 process() 后都重建视图。

11.6 零拷贝模式详解

零拷贝(zero-copy)是指数据在不复制的情况下在 JS 和 WASM 之间传递。WASM 的线性内存模型天然支持零拷贝——只要 JS 和 WASM 访问同一块内存地址。

11.6.1 预分配缓冲区模式

#[wasm_bindgen]
pub struct Pipeline {
    input_buf: Vec<u8>,
    output_buf: Vec<u8>,
}

#[wasm_bindgen]
impl Pipeline {
    #[wasm_bindgen(constructor)]
    pub fn new(buf_size: usize) -> Pipeline {
        Pipeline {
            input_buf: vec![0; buf_size],
            output_buf: vec![0; buf_size],
        }
    }

    pub fn input_ptr(&mut self) -> *mut u8 {
        self.input_buf.as_mut_ptr()
    }

    pub fn output_ptr(&self) -> *const u8 {
        self.output_buf.as_ptr()
    }

    pub fn input_len(&self) -> usize {
        self.input_buf.len()
    }

    pub fn output_len(&self) -> usize {
        self.output_buf.len()
    }

    pub fn process(&mut self, input_len: usize) -> usize {
        // 从 input_buf[0..input_len] 读取
        // 写到 output_buf[0..output_len]
        // 返回 output_len
        output_len
    }
}

JS 侧:

const pipeline = new Pipeline(1024 * 1024);  // 1MB

function processFrame(frameData) {
    // 写入输入缓冲区
    const inputView = new Uint8Array(wasm.memory.buffer, pipeline.input_ptr(), pipeline.input_len());
    inputView.set(frameData, 0);

    // 处理
    const outputLen = pipeline.process(frameData.length);

    // 读取输出缓冲区
    const outputView = new Uint8Array(wasm.memory.buffer, pipeline.output_ptr(), pipeline.output_len());
    return outputView.slice(0, outputLen);
}

这个模式的关键约束:input_buf 和 output_buf 的地址在 Pipeline 的生命周期内不变——因为 Vec 只在 new() 时分配一次,process() 不做任何 push/pop,只修改已有的元素。如果 process() 触发了 Vec 的重新分配(比如意外 push),地址会改变,JS 侧的视图就失效了。

11.6.2 循环缓冲区模式

对于流式数据(如音频流、视频帧),可以用循环缓冲区(ring buffer)实现零拷贝的双向通信:

#[wasm_bindgen]
pub struct RingBuffer {
    data: Vec<u8>,
    read_pos: usize,   // 单调递增,取模后才是下标
    write_pos: usize,
}

#[wasm_bindgen]
impl RingBuffer {
    #[wasm_bindgen(constructor)]
    pub fn new(size: usize) -> RingBuffer {
        RingBuffer {
            data: vec![0; size],
            read_pos: 0,
            write_pos: 0,
        }
    }

    // 注意必须是 &mut self —— Vec::as_mut_ptr 需要可变借用
    pub fn write_ptr(&mut self) -> *mut u8 {
        let pos = self.write_pos % self.data.len();
        unsafe { self.data.as_mut_ptr().add(pos) }
    }

    // 从 write_ptr 起能连续写入的字节数:既不能超过空闲空间,也不能越过缓冲区末尾
    pub fn available_write(&self) -> usize {
        let free = self.data.len() - (self.write_pos - self.read_pos);
        let to_end = self.data.len() - self.write_pos % self.data.len();
        free.min(to_end)
    }

    pub fn commit_write(&mut self, len: usize) {
        assert!(len <= self.available_write());
        self.write_pos += len;
    }

    pub fn read(&mut self, out: &mut [u8]) -> usize {
        let cap = self.data.len();
        let to_read = (self.write_pos - self.read_pos).min(out.len());
        for (i, b) in out[..to_read].iter_mut().enumerate() {
            *b = self.data[(self.read_pos + i) % cap];
        }
        self.read_pos += to_read;
        to_read
    }
}

JS 侧先查 available_write(),往 write_ptr() 指向的内存里最多写这么多字节,再调用 commit_write(len) 通知 WASM 数据已写入;写到缓冲区末尾时,下一轮 write_ptr() 会绕回开头。写入方向是真正的零复制、零分配。读出方向要注意:read(out: &mut [u8]) 由 wasm-bindgen 转换,JS 传入的数组会先被复制进线性内存、函数返回后再复制回去——想让读出也零复制,就对称地暴露 read_ptr() / available_read() / commit_read(),让 JS 直接从线性内存视图里取。(上面的代码在 wasm-bindgen 0.2.118 + node v24 实跑过绕回的情形。)

11.7 Web Worker 通信

WASM 在主线程上执行时会阻塞 UI——如果计算时间超过 16ms(一帧的时间),页面会出现掉帧。Web Worker 允许在后台线程执行 WASM,但引入了线程间通信的开销。

11.7.1 postMessage 与结构化克隆

主线程和 Worker 之间通过 postMessage 通信。默认的通信机制是结构化克隆(structured clone)——数据被递归复制到接收方。

// 主线程
const worker = new Worker('worker.js');
worker.postMessage({ data: largeArray });  // largeArray 被完整复制

// Worker
self.onmessage = (e) => {
    const data = e.data.data;  // data 是 largeArray 的副本
};

结构化克隆的开销随数据大小增长——node v24 的 worker_threads 里实测,1MB 的 Uint8Array 一来一回约 1.9 ms,发送端 postMessage 同步阻塞就占约 0.9 ms(§11.7.5)。对于 WASM 的线性内存(可能几十 MB),这个开销不可接受。还要注意:克隆一个 TypedArray 视图时,被复制的是它背后的整个 ArrayBuffer——对线性内存上的 16 字节 subarray 做 structuredClone,实测得到的是一份完整的线性内存副本(本例 1.2MB)。

11.7.2 Transferable Objects

Transferable Objects 允许 ArrayBuffer 在主线程和 Worker 之间转移所有权——零复制,只是指针转移。

// 主线程
const wasmBuffer = new ArrayBuffer(10 * 1024 * 1024);  // 10MB
const view = new Uint8Array(wasmBuffer);
// ... 写入数据到 view ...

// 转移所有权——零复制
worker.postMessage({ buffer: wasmBuffer }, [wasmBuffer]);

// wasmBuffer 现在在主线程上被分离(detached)——不能再用
console.log(wasmBuffer.byteLength);  // 0

// Worker
self.onmessage = (e) => {
    const buffer = e.data.buffer;  // 现在 Worker 拥有这个 buffer
    const view = new Uint8Array(buffer);
};

关键约束:转移后发送方不能继续使用该 ArrayBuffer。如果主线程和 Worker 需要同时访问同一块内存,需要 SharedArrayBuffer。

11.7.3 SharedArrayBuffer

SharedArrayBuffer 允许主线程和 Worker 同时访问同一块内存——真正的零拷贝、零转移通信。

// 主线程
const shared = new SharedArrayBuffer(10 * 1024 * 1024);  // 10MB
const view = new Uint8Array(shared);

// 共享给 Worker——双方都持有引用
worker.postMessage({ shared }, []);

// Worker
self.onmessage = (e) => {
    const view = new Uint8Array(e.data.shared);  // 同一块内存
    // 读写 view——主线程能看到变化
};

SharedArrayBuffer 的安全要求:页面必须启用 cross-origin-isolated 策略:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

这是因为 Spectre:2018 年初各浏览器一度停用了共享内存和高精度计时器,2020 年标准化了以"安全上下文 + 跨源隔离"为前提的重新启用方案(MDN SharedArrayBuffer 的 Security requirements 一节)。页面是否满足条件,可以用 crossOriginIsolated 属性检查。

11.7.4 WASM + Worker 的架构模式

graph TB
  subgraph "主线程"
    A["UI 渲染"] --- B["JS 事件处理"]
    B --- C["WASM 轻量计算<br/>< 5ms"]
  end
  subgraph "Worker 线程"
    D["WASM 重量计算<br/>图像/音频/AI"] --- E["SharedArrayBuffer<br/>或 Transferable"]
  end
  C <-->|"postMessage"| E
  style A fill:#6366f1,color:#fff
  style C fill:#10b981,color:#333
  style D fill:#f59e0b,color:#333

模式一:Worker 初始化 WASM,主线程通过 postMessage 发送命令。Worker 执行计算后通过 Transferable ArrayBuffer 返回结果。适合"一问一答"模式——如图片处理、文件解析。

模式二:主线程和 Worker 共享 SharedArrayBuffer,通过 Atomics 同步。适合流式处理——如实时音频处理,Worker 持续消费主线程写入的数据。

模式三:每个 Worker 拥有独立的 WASM 模块实例。适合并行计算——如将图像分成 N 块,每个 Worker 处理一块,最后合并结果。

11.7.5 Worker 通信的开销实测

node v24.20.0 的 worker_threads(与浏览器 Worker 的实现不同,只看关系),一来一回的往返时间取 41 次中位数,连跑三轮:

通信方式 数据大小 往返时间 发送端 postMessage 同步耗时
空消息(对照) 0 ≈ 0.03 ms ≈ 0.01 ms
postMessage(结构化克隆) 1 KB ≈ 0.03 ms ≈ 0.01 ms
postMessage(结构化克隆) 1 MB ≈ 1.9 ms ≈ 0.9 ms
postMessage(结构化克隆) 10 MB 4–10 ms(波动大) ≈ 1.5 ms
Transferable ArrayBuffer 1 KB ≈ 0.03 ms ≈ 0.01 ms
Transferable ArrayBuffer 10 MB ≈ 0.07 ms ≈ 0.02 ms

SharedArrayBuffer 这一侧测的是写入本身:往 10MB 的共享缓冲区 set 一份数据约 1.2 ms;逐个 Int32Array 元素普通赋值约 3.3 ms,同样的循环全换成 Atomics.store 约 47 ms,贵了一个数量级。

要记住的是关系而不是数值:结构化克隆的成本随数据量增长,小消息则被固定的消息调度开销淹没;Transferable 的成本基本与数据量无关(只转移 buffer 所有权);SharedArrayBuffer 则连转移都省了。Atomics.store 明显贵于普通写入,所以共享内存方案里"每写一个元素都用 Atomics"是典型的过度同步——正确做法是普通写入填充数据、最后用一次 Atomics.store + Atomics.notify 发布。

11.8 通信开销测量实战

以一个 4K 图像(3840×2160)的灰度转换为例,对比不同通信策略的性能(环境见 §11.3.4 前的说明;SIMD 版另用 -C target-feature=+simd128 构建)。

11.8.1 基线:逐像素调用

#[wasm_bindgen]
pub fn to_grayscale(r: u8, g: u8, b: u8) -> u8 {
    (r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114) as u8
}

JS 侧逐像素调用:

for (let i = 0; i < pixels.length; i += 4) {
    gray[i >> 2] = wasm.to_grayscale(pixels[i], pixels[i+1], pixels[i+2]);
}

性能:4K 图像有 8,294,400 像素,也就是 800 多万次跨边界调用。按 §10.5 实测的"数值签名一次调用约 5 ns"估算,光调用开销就是 40 ms 上下;实测整轮约 115 ms,是下面批量方案的两倍多。这比很多人想象的要好——V8 对数值签名的 JS→WASM 调用已经很便宜——但它把计算切成了 800 万段,任何向量化、循环优化都无从谈起,参数一旦换成字符串或对象(§10.5 那一档),就会再贵一个数量级以上。

11.8.2 优化一:批量传递

#[wasm_bindgen]
pub fn to_grayscale_batch(data: &[u8]) -> Vec<u8> {
    data.chunks(4).map(|px| {
        (px[0] as f32 * 0.299 + px[1] as f32 * 0.587 + px[2] as f32 * 0.114) as u8
    }).collect()
}

性能:1 次跨边界调用 + 胶水把 33MB 输入复制进线性内存、再把 8MB 结果复制出来 → 约 53 ms。其中复制只占一成左右(单独测 33MB 的 TypedArray.set 约 4.5 ms),大头是灰度计算本身。

11.8.3 优化二:指针传递

use std::slice;

#[wasm_bindgen]
pub fn to_grayscale_at(ptr: *const u8, len: usize, out_ptr: *mut u8) {
    let data = unsafe { slice::from_raw_parts(ptr, len) };
    let out = unsafe { slice::from_raw_parts_mut(out_ptr, len / 4) };
    for (i, px) in data.chunks(4).enumerate() {
        out[i] = (px[0] as f32 * 0.299 + px[1] as f32 * 0.587 + px[2] as f32 * 0.114) as u8;
    }
}

性能:JS 侧自己 malloc 输入/输出缓冲区、set 写入输入、调用、直接在输出视图上读、再 free → 约 53 ms,与批量传递持平。输入那次复制并没有省掉(数据本来就在 JS 数组里),省掉的只是 8MB 的结果复制,而它在这里无足轻重。

11.8.4 优化三:共享缓冲区

#[wasm_bindgen]
pub struct GrayscalePipeline {
    buffer: Vec<u8>,
    output: Vec<u8>,
}

#[wasm_bindgen]
impl GrayscalePipeline {
    pub fn new(max_pixels: usize) -> GrayscalePipeline {
        GrayscalePipeline {
            buffer: vec![0; max_pixels * 4],
            output: vec![0; max_pixels],
        }
    }

    pub fn buffer_ptr(&mut self) -> *mut u8 { self.buffer.as_mut_ptr() }
    pub fn output_ptr(&self) -> *const u8 { self.output.as_ptr() }

    pub fn process(&mut self, len: usize) -> usize {
        for i in 0..len/4 {
            let r = self.buffer[i*4] as f32;
            let g = self.buffer[i*4+1] as f32;
            let b = self.buffer[i*4+2] as f32;
            self.output[i] = (r * 0.299 + g * 0.587 + b * 0.114) as u8;
        }
        len / 4
    }
}

性能:每帧只剩把输入 set 进预分配缓冲区 + 1 次调用 → 约 52 ms(其中 process 本身约 47 ms)。比指针传递只快了 1 ms 上下——一对 malloc/free 的开销本来就很小。

11.8.5 优化四:SIMD + 共享缓冲区

#[cfg(target_feature = "simd128")]
unsafe fn to_grayscale_simd(data: &[u8], out: &mut [u8]) {
    use core::arch::wasm32::*;
    let (wr, wg, wb) = (f32x4_splat(0.299), f32x4_splat(0.587), f32x4_splat(0.114));
    let mask = u32x4_splat(0xff);
    let n = data.len() / 16; // 每次处理 4 个 RGBA 像素
    for i in 0..n {
        let px = unsafe { v128_load(data.as_ptr().add(i * 16) as *const v128) };
        // 小端序下每个 u32 通道是 0xAABBGGRR:移位 + 掩码拆出 R/G/B
        let r = f32x4_convert_u32x4(v128_and(px, mask));
        let g = f32x4_convert_u32x4(v128_and(u32x4_shr(px, 8), mask));
        let b = f32x4_convert_u32x4(v128_and(u32x4_shr(px, 16), mask));
        let y = f32x4_add(f32x4_add(f32x4_mul(r, wr), f32x4_mul(g, wg)), f32x4_mul(b, wb));
        let yi = u32x4_trunc_sat_f32x4(y);
        // 取 4 个 u32 的最低字节拼成 4 字节输出
        let packed = i8x16_shuffle::<0, 4, 8, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>(yi, yi);
        let w = i32x4_extract_lane::<0>(packed) as u32;
        out[i * 4..i * 4 + 4].copy_from_slice(&w.to_le_bytes());
    }
    for j in n * 4..data.len() / 4 { // 尾部不足 4 个像素的走标量
        let px = &data[j * 4..];
        out[j] = (px[0] as f32 * 0.299 + px[1] as f32 * 0.587 + px[2] as f32 * 0.114) as u8;
    }
}

性能:一次处理 4 个像素,process 本身降到约 12 ms(标量版约 47 ms,快 3.8 倍左右),加上输入的 set 共约 17 ms;输出与标量版逐字节一致。

策略 耗时 相对批量传递 跨边界调用 数据复制 内存分配
逐像素调用 ≈ 115 ms 慢约 2.2 倍 8M 次 0 0
批量传递 ≈ 53 ms 1x(基准) 1 次 33MB 进 + 8MB 出 2 alloc + 2 free
指针传递 ≈ 53 ms ≈ 1x 1 次 33MB 进 2 alloc + 2 free
共享缓冲区 ≈ 52 ms ≈ 1x 1 次 33MB 进 0
SIMD + 共享缓冲区 ≈ 17 ms ≈ 3x 1 次 33MB 进 0

这个案例的结论和直觉不太一样:第一步必须做的是消灭逐元素的跨边界调用(从逐像素到批量,两倍多);之后在复制和分配上做文章,对这个计算量足够大的负载几乎没有收益;真正再拉开差距的是计算本身(SIMD 约 3 倍)。每个元素上的计算越轻,复制和分配在总耗时里占比越高,§11.3 的指针传递和共享缓冲区才越值钱——所以动手之前先把"复制"和"计算"分开测一遍。

11.9 内存泄漏的检测与预防

WASM 的内存泄漏比原生平台更隐蔽——没有 valgrind,没有 AddressSanitizer,没有 segfault 提醒你越界访问。线性内存只增不减——分配器回收的内存可以重用,但 memory.grow 申请的页永远不会归还。

11.9.1 常见的泄漏模式

  1. 忘记调用 free():JS 侧持有 Rust 对象的包装类,但没有调用 free() 方法。Rust 侧的内存只能等胶水里的 FinalizationRegistry 在 GC 之后兜底释放(§11.4.3),时机不可控;包装对象若一直可达(比如挂在全局缓存里),就真的永远不会释放。

  2. 闭包泄漏:Closure::forget() 等价于 mem::forget——闭包和它捕获的数据彻底泄漏,谁都回收不了(§7.10)。把回收责任交给 JS GC 的是 into_js_value(),但那条路也只在宿主支持弱引用时才真的回收。

  3. 循环引用:Rust 对象持有 JsValue,JS 对象持有 Rust 对象的指针——两个方向的引用阻止了 GC 回收。

  4. 全局 Vec 持续增长:WASM 侧的全局 Vec 只有 push 没有 clear——线性内存持续增长。

11.9.2 检测方法

方法一:监控线性内存增长

function checkMemory() {
    const pages = wasm.memory.buffer.byteLength / 65536;
    console.log(`WASM memory: ${pages} pages (${pages * 64}KB)`);
}
setInterval(checkMemory, 1000);

如果页面数持续增长不回收,说明有内存泄漏。这是最简单也最有效的检测方法——因为 WASM 的线性内存是全局可见的。

方法二:在 Rust 侧统计分配

一个流传很广的做法是在 JS 里把 wasm.__wbindgen_malloc / wasm.__wbindgen_free 替换成带记录的包装函数——这行不通:WebAssembly.Instance 的 exports 对象是冻结的,ES 模块里赋值直接抛 TypeError: Cannot assign to read only property '__wbindgen_malloc'(node v24 实跑)。况且这两个导出只在胶水传参、回收返回值时才被调用,Rust 内部的 Vec/String 分配根本不经过它们。

正确的位置是 Rust 的全局分配器:包一层计数的 GlobalAlloc,再导出一个查询函数(下面这段在 0.2.118 + node v24 实跑过):

use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
use wasm_bindgen::prelude::*;

struct Counting;
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
static FREES: AtomicUsize = AtomicUsize::new(0);
static LIVE_BYTES: AtomicUsize = AtomicUsize::new(0);

unsafe impl GlobalAlloc for Counting {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        ALLOCS.fetch_add(1, Relaxed);
        LIVE_BYTES.fetch_add(layout.size(), Relaxed);
        unsafe { System.alloc(layout) } // wasm32-unknown-unknown 上 System 就是 dlmalloc
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        FREES.fetch_add(1, Relaxed);
        LIVE_BYTES.fetch_sub(layout.size(), Relaxed);
        unsafe { System.dealloc(ptr, layout) }
    }
}

#[global_allocator]
static GLOBAL: Counting = Counting;

#[wasm_bindgen]
pub fn alloc_stats() -> Vec<u32> {
    vec![ALLOCS.load(Relaxed) as u32, FREES.load(Relaxed) as u32, LIVE_BYTES.load(Relaxed) as u32]
}

JS 侧定时调 alloc_stats(),看 LIVE_BYTES 是否只涨不回落即可(未开 atomics 时这些 fetch_add 会编译成普通的读-改-写指令,没有额外开销)。需要调用栈时,在 debug 构建里对超过阈值的分配调用一个导入的 JS 函数、在那边记录 new Error().stack。

方法三:Chrome DevTools Memory 面板

Chrome 的 Memory 面板可以拍摄堆快照,对比两次快照之间的差异。WASM 的线性内存作为 ArrayBuffer 出现在快照中——如果 ArrayBuffer 持续增大,说明 WASM 侧有泄漏。

11.9.3 预防策略

  1. RAII 模式:在 Rust 侧用 Drop 实现自动清理,尽量不把需要手动 free() 的对象暴露给 JS。胶水自带的 FinalizationRegistry 只是兜底(§11.4.3),不要把及时释放寄托在它上面。
  2. 所有权的文档化:在 API 文档中明确说明哪个方向负责释放内存。
  3. Closure 的生命周期管理:优先使用 Closure::once 而非 Closure::wrap——一次性闭包的内存管理更简单。
  4. 定期压力测试:在循环中反复创建/销毁对象,观察线性内存是否稳定。
  5. panic 之后不要继续用同一个实例:#[wasm_bindgen] 的导出函数里发生 panic 时,Rust 的栈展开在 wasm32 上通常被编译成 abort,中途分配的内存不会被回收,被 panic 打断的数据结构也可能处于不一致状态。生产代码应在导出层用 Result 而不是 panic 表达错误。

11.10 Canvas/WebGL 与 WASM 的零拷贝

图像处理、游戏渲染、视频编辑——这些场景的共同特征是数据在 WASM 和 GPU 之间频繁流动。如果每帧都要把数据从 WASM 内存拷贝到 Canvas 再上传到 GPU,性能瓶颈就在拷贝上而不是计算上。

11.10.1 Canvas 2D 的数据通路

CanvasRenderingContext2D.putImageData() 接受一个 ImageData 对象,其内部是一个 Uint8ClampedArray。如果这个数组直接是 WASM 内存的视图,就省掉了"从线性内存复制到 JS 数组"这一步(putImageData 把像素写进画布时的那次复制仍然存在):

const ptr = wasm.exports.render_frame();
const len = 1920 * 1080 * 4;
// 关键:直接构造 WASM 内存的视图,不复制
const pixels = new Uint8ClampedArray(wasm.exports.memory.buffer, ptr, len);
const imageData = new ImageData(pixels, 1920, 1080);
ctx.putImageData(imageData, 0, 0);

但有一个陷阱:ImageData 的构造函数要求 Uint8ClampedArray 的长度精确匹配 width * height * 4。如果 WASM 分配的内存有 padding,构造会失败。

另一个陷阱是 memory.grow 后视图失效。如果 render_frame() 内部触发了内存增长,之前创建的 Uint8ClampedArray 指向的是已被 detach 的旧 ArrayBuffer,长度归零——putImageData 拿到的是一个已经失效的数组,结果不可预期。安全做法:每帧重新构造视图,或确保 WASM 不在渲染热路径调用 memory.grow。

11.10.2 WebGL 纹理上传的优化路径

WebGL 的 gl.texImage2D() 接受多种数据源:HTMLImageElement、ImageBitmap、ArrayBufferView。从 WASM 内存上传纹理:

const ptr = wasm.exports.compute_texture();
const data = new Uint8Array(wasm.exports.memory.buffer, ptr, 1024 * 1024 * 4);
gl.texImage2D(
    gl.TEXTURE_2D, 0, gl.RGBA, 1024, 1024, 0,
    gl.RGBA, gl.UNSIGNED_BYTE, data
);

gl.texImage2D 会把数据从 WASM 内存拷贝到 GPU 显存——CPU→GPU 的拷贝是必须的(不同的内存域),但 WASM→JS 的拷贝可以省掉。

三条通路按 CPU 拷贝次数排序(本章的测量环境没有 GPU 与浏览器,这里只给定性关系):

通路 CPU 拷贝 说明
WASM→Vec→JS Uint8Array→texImage2D 两次 多一次线性内存到 JS 数组的复制
WASM 内存视图→texImage2D 一次 只剩上传本身(GPU 上传不可省)
OffscreenCanvas + ImageBitmap 视实现而定 浏览器内部路径,是否更快要在目标浏览器上实测

11.10.3 OffscreenCanvas + WASM Worker

把 WASM 渲染移到 Worker 线程,避免阻塞主线程:

sequenceDiagram
    participant M as 主线程
    participant W as Worker
    participant WASM as WASM
    participant GPU as GPU
    M->>W: postMessage(canvas.transferControlToOffscreen())
    W->>WASM: 实例化 WASM
    loop 每帧
        WASM->>WASM: 计算下一帧像素
        WASM->>W: 内存指针
        W->>GPU: gl.texImage2D(WASM 视图)
        W->>GPU: gl.drawArrays
    end

canvas.transferControlToOffscreen() 把 Canvas 的控制权转移给 Worker——主线程上的页面交互(点击、滚动)不会被渲染阻塞。这是浏览器游戏和实时视频处理的标准架构。

11.10.4 WebCodecs:跳过 Canvas 直接编解码

新一代 API WebCodecs(Chrome 94+,Safari 16.4+)允许 WASM 直接生成 VideoFrame 对象,跳过 Canvas 中转:

const ptr = wasm.exports.decode_frame();
const data = new Uint8Array(wasm.exports.memory.buffer, ptr, 1920 * 1080 * 1.5); // YUV420
const frame = new VideoFrame(data, {
    format: 'I420', codedWidth: 1920, codedHeight: 1080,
    timestamp: performance.now() * 1000,
});
await writer.write(frame);  // writer 取自 MediaStreamTrackGenerator / VideoTrackGenerator 的 writable

要注意,用 BufferSource 构造 VideoFrame 时浏览器会复制一份数据(MDN:只有把底层 ArrayBuffer 放进 transfer 选项才会直接接管、不复制)——而线性内存的 buffer 不能被转移(§11.13.2),所以从 WASM 内存构造帧总要付一次复制。它省掉的是 Canvas 中转(画到 Canvas 再从 Canvas 取帧)那一段,而不是这次复制。

11.11 流式数据处理:超出内存的数据

当数据量逼近 WASM 线性内存的上限(32 位线性内存的地址空间上限是 4GiB / 65536 页,实际可分配量还要受设备内存和引擎策略约束,远达不到理论值),或者数据来自实时网络流,必须用流式处理——分块送入 WASM、分块输出,永不在内存中保存完整数据。

11.11.1 ReadableStream → WASM 的协议

async function processStream(url, wasmProcessor) {
    const response = await fetch(url);
    const reader = response.body.getReader();

    while (true) {
        const { value, done } = await reader.read();
        if (done) break;

        // 把这块数据送入 WASM
        const ptr = wasmProcessor.alloc(value.length);
        const wasmMem = new Uint8Array(wasmProcessor.memory.buffer, ptr, value.length);
        wasmMem.set(value);
        wasmProcessor.process_chunk(ptr, value.length);
        wasmProcessor.dealloc(ptr, value.length);
    }

    wasmProcessor.finalize();
}

上面是按 (ptr, len) 手动导出时的协议。WASM 侧用增量算法处理——例如流式哈希(每来一块数据 update 一次,最后 finalize 输出哈希值);下面这个版本改用 wasm-bindgen 的 &[u8] 参数,JS 侧直接 hasher.process_chunk(value) 即可,malloc、复制、释放都由胶水代劳(sha2 0.10.9,实编实跑):

use sha2::{Digest, Sha256}; // Sha256::new 来自 Digest trait,缺了它编译报 E0599

#[wasm_bindgen]
pub struct StreamHasher {
    hasher: Sha256,
}

#[wasm_bindgen]
impl StreamHasher {
    #[wasm_bindgen(constructor)]
    pub fn new() -> StreamHasher {
        StreamHasher { hasher: Sha256::new() }
    }

    pub fn process_chunk(&mut self, data: &[u8]) {
        self.hasher.update(data);
    }

    pub fn finalize(self) -> Vec<u8> {
        self.hasher.finalize().to_vec()
    }
}

11.11.2 背压控制

流式处理的关键是生产速度不能超过消费速度——否则 WASM 处理不过来,数据积压在 JS 侧的 buffer,最终 OOM。

ReadableStream 的 reader 默认有背压控制:每次 reader.read() 等到上一块处理完才请求下一块。但如果 JS 侧用 Promise.all 并发处理多个块,背压机制就失效了——必须改回顺序处理。

flowchart LR
  A["网络流"] --> B["JS Reader"]
  B --> C{"WASM 在处理上一块?"}
  C -->|是| D["背压:暂停下载"]
  C -->|否| E["送入 WASM"]
  E --> F["WASM 处理"]
  F --> G["输出 chunk"]
  D --> C
  style D fill:#f59e0b,color:#333
  style F fill:#10b981,color:#333

11.11.3 流式输出:WASM → JS

WASM 处理后的输出也可能是流——例如解压一个 100MB 的 zstd 文件,输出可能是 1GB 的解压数据。一次性返回需要 1GB 内存,必须分块返回(下面是结构示意,解码器类型与错误处理从略):

#[wasm_bindgen]
pub struct StreamDecompressor {
    decoder: zstd::stream::Decoder<...>,
    output_buf: Vec<u8>,
}

#[wasm_bindgen]
impl StreamDecompressor {
    pub fn process(&mut self, input: &[u8]) -> Vec<u8> {
        self.output_buf.clear();
        self.decoder.write(input);
        // 读取已解压的输出(可能 0 字节,可能很多字节)
        self.decoder.read_to_end(&mut self.output_buf);
        // 交出所有权而不是 clone:返回 Vec<u8> 时胶水还会再复制一次到 JS(§11.4.2),
        // 那一次无法避免,没必要在 Rust 侧再多复制一次
        std::mem::take(&mut self.output_buf)
    }
}

JS 侧把每块输出写入 WritableStream:

const writer = output.getWriter();
for (const inputChunk of inputStream) {
    const outputChunk = decoder.process(inputChunk);
    if (outputChunk.length > 0) {
        await writer.write(outputChunk);
    }
}
await writer.close();

11.12 内存泄漏案例剖析

理论的泄漏模式不如具体案例直观。下面三个案例按常见的出错模式整理,数字只是示意。

11.12.1 案例:每次路由切换都 forget 一个 Closure

症状:SPA 应用,用户切换页面 50 次后内存增长 10MB,最终崩溃。

定位流程:

  1. Chrome Memory 面板拍快照——发现 WebAssembly.Memory.buffer 持续增长,每次切换 +200KB
  2. 用 §11.9.2 的 Rust 侧分配统计确认——每次切换都有一批分配再也没有释放,大小与闭包捕获的状态吻合
  3. 检查代码——每次进入页面都用 Closure::wrap 包装一个回调(捕获了页面状态),传给 addEventListener 后立刻 closure.forget();离开页面时既没有 removeEventListener,也已经没有 Closure 可供 drop

修复:

// Bug: 为了不让闭包在作用域结束时被 drop,直接 forget——每进一次页面泄漏一份
fn setup_page() {
    let closure = Closure::wrap(Box::new(|_| { /* ... */ }) as Box<dyn FnMut(_)>);
    add_event_listener("click", &closure);
    closure.forget(); // 闭包连同捕获的状态永不回收
}
// 反过来,如果既不 forget 也不保存,closure 在函数末尾被 drop,
// 之后点击时 JS 调用会抛 "closure invoked recursively or after being dropped"(§7.10)

// 修复:用结构体持有 Closure,离开页面时先解绑、再随结构体一起 drop
struct PageHandler {
    target: web_sys::EventTarget,
    click_handler: Closure<dyn FnMut(web_sys::Event)>,
}

impl Drop for PageHandler {
    fn drop(&mut self) {
        let _ = self.target.remove_event_listener_with_callback(
            "click",
            self.click_handler.as_ref().unchecked_ref(),
        );
        // 函数返回后 click_handler 字段被 drop,释放 WASM 侧状态
    }
}

教训:Closure 在 Rust 侧被 drop 时会释放它在 WASM 侧的状态,并让对应的 JS 函数失效。确保 Closure 的生命周期覆盖 JS 侧持有它的整个时间——要么用结构体持有到解绑为止,要么用 forget() 明确宣布"这个闭包活到页面结束"(这本身就意味着永不回收,只适合真正全局的监听器)。

11.12.2 案例:全局 Vec 缓存增长无界

症状:长时间运行的页面,每过 1 小时 WASM 内存增长 50MB。

定位流程:

  1. 监控线性内存——确认增长在 WASM 侧
  2. 用 twiggy 看不出问题(静态体积没变)
  3. 在 WASM 侧加入诊断函数——每分钟报告全局静态变量的大小
  4. 发现一个 static MUTEX_CACHE: Lazy<Mutex<HashMap<String, Vec<u8>>>>,记录每个 API 请求的响应——只 insert 没 evict

修复:把 HashMap 换成 LRU cache:

use lru::LruCache;

static CACHE: Lazy<Mutex<LruCache<String, Vec<u8>>>> = Lazy::new(|| {
    Mutex::new(LruCache::new(NonZeroUsize::new(100).unwrap()))
});

教训:WASM 的全局静态变量没有 GC 兜底——必须设计明确的 evict 策略。任何 HashMap/Vec 作为静态缓存的代码都要审计是否有 evict。

11.12.3 案例:缓存的内存视图在 memory.grow 之后静默失效

症状:一个 WASM 图像处理组件,前几十张图都正常,之后突然开始输出全黑的图。没有报错,没有异常,控制台干干净净。

定位流程:

  1. 在 JS 侧打印读回的字节数组——全是 undefined
  2. 打印视图的 byteLength——0
  3. 打印 view.buffer === wasm.memory.buffer——false
  4. 真正原因:JS 侧为了省开销,把 Uint8Array 视图缓存成了模块级变量;处理到某张更大的图时 Rust 侧的 Vec 扩容触发了 memory.grow,旧的 ArrayBuffer 被 detach,缓存的视图长度归零。detached 视图的索引读返回 undefined、索引写被丢弃,全程不抛异常——这正是"静默失效"最难查的地方

修复:照抄 wasm-bindgen 的防御式取用(§11.5.3):

// Bug: 视图缓存一次,之后再也不检查;逐字节索引读,detach 后静默得到 undefined
const view = new Uint8Array(wasm.memory.buffer);
function read(ptr, len) { return Array.from({ length: len }, (_, i) => view[ptr + i]); }
// (若这里用 view.subarray / view.slice,detach 后会直接抛 TypeError,反而好查)

// 修复:每次取用前检查是否已 detach
let cachedView = null;
function getView() {
    if (cachedView === null || cachedView.byteLength === 0) {
        cachedView = new Uint8Array(wasm.memory.buffer);
    }
    return cachedView;
}
function read(ptr, len) { return getView().slice(ptr, ptr + len); }  // 复制出来,不把活视图交给调用方

教训:只要 WASM 侧还可能分配内存,JS 侧就不存在"可以长期持有"的线性内存视图。要么每次重新取(配合 byteLength === 0 判据,开销可以忽略),要么在初始化阶段一次性把内存预分配到位、从此不再增长。另外注意:跨 postMessage 传 TypedArray 时结构化克隆会复制出一个独立 buffer(而且复制的是整个底层 buffer,§11.7.1),Worker 拿到的从来就不是共享视图——真要共享必须用 SharedArrayBuffer。

11.13 跨上下文通信的高级模式

§11.7 介绍了基础的 Web Worker 通信。生产场景常常更复杂——多个 WASM 实例分布在不同上下文(主线程、Worker、iframe、Service Worker)中,相互之间需要传递数据。这些跨上下文通信比单向的 main↔worker 复杂得多。

11.13.1 通信拓扑与传递媒介

graph TD
  subgraph "浏览器进程"
    M["主线程 WASM"]
    W1["Worker 1 WASM"]
    W2["Worker 2 WASM"]
    SW["Service Worker WASM"]
  end
  IF["iframe WASM<br/>跨 origin"]
  M -.postMessage.-> W1
  M -.postMessage.-> W2
  M -.SharedArrayBuffer.-> W1
  W1 -.MessageChannel.-> W2
  M -.fetch event.-> SW
  M -.postMessage.-> IF
  style M fill:#10b981,color:#333
  style W1 fill:#6366f1,color:#fff
  style W2 fill:#6366f1,color:#fff
  style SW fill:#f59e0b,color:#333
  style IF fill:#dc2626,color:#fff

每对上下文之间的"最佳通信媒介"不同:

拓扑 推荐媒介 数据传递成本
主线程 ↔ Worker postMessage / SharedArrayBuffer 复制 / 零拷贝
Worker ↔ Worker MessageChannel + 转移 复制 / 零拷贝(Transferable)
主线程 ↔ Service Worker fetch event / postMessage 复制
主线程 ↔ 同 origin iframe postMessage / SharedArrayBuffer 复制 / 零拷贝
主线程 ↔ 跨 origin iframe postMessage(数据复制) 复制(强制)

11.13.2 Transferable:避免数据复制的关键

postMessage 默认对数据做结构化克隆(Structured Clone)——大数据复制开销显著。Transferable 让所有权直接转移:

// 反模式:1MB 数据被结构化克隆复制一份
const data = new Uint8Array(1024 * 1024);
worker.postMessage(data);  // 复制到 Worker

// 推荐:所有权转移,零拷贝
const buffer = new ArrayBuffer(1024 * 1024);
const data = new Uint8Array(buffer);
worker.postMessage(data, [buffer]);  // 转移 ArrayBuffer
// 注意:转移后主线程的 data 无效(buffer 已被 detached)

可转移类型:

类型 是否可转移
ArrayBuffer ✓
MessagePort ✓
ImageBitmap ✓
OffscreenCanvas ✓
ReadableStream ✓
Uint8Array 等视图 ✗(转移其 underlying buffer)
普通对象 ✗(只能克隆)

WASM 内存的视图传给 Worker 时——必须先复制到独立 ArrayBuffer 再转移:线性内存的 ArrayBuffer 归 WebAssembly.Memory 所有,不能被转移(node v24 实测把 wasm.memory.buffer 放进 transfer 列表抛 DataCloneError),直接克隆视图又会把整个线性内存复制一遍:

// WASM 内存视图(不可直接转移)
const wasmView = new Uint8Array(wasm.memory.buffer, ptr, len);

// 复制到独立 buffer
const standalone = new Uint8Array(len);
standalone.set(wasmView);

// 转移
worker.postMessage(standalone, [standalone.buffer]);

11.13.3 MessageChannel:Worker 之间的直接通信

默认情况下,Worker A 给 Worker B 发消息必须经过主线程中转——主线程成为瓶颈。MessageChannel 让 Worker 之间建立直接通道:

// 主线程:创建 channel + 分发 port
const channel = new MessageChannel();
const port1 = channel.port1;
const port2 = channel.port2;

worker1.postMessage({ port: port1 }, [port1]);  // 转移 port 给 Worker 1
worker2.postMessage({ port: port2 }, [port2]);  // 转移 port 给 Worker 2
// 此后 Worker 1 和 Worker 2 可以直接通信,主线程不参与

// Worker 1 内
self.onmessage = (e) => {
    const port = e.data.port;
    port.onmessage = (msg) => {
        // 直接收到 Worker 2 的消息
    };
    port.postMessage('hello');  // 直接发给 Worker 2
};

适用:流水线式数据处理——Worker A 抽取 → Worker B 转换 → Worker C 输出,每对之间独立 channel。主线程只负责编排。

11.13.4 SharedArrayBuffer 与 Atomics:真正的零拷贝

跨 Worker 的最强大通信媒介是 SharedArrayBuffer——多个 Worker 看到同一份内存:

// 主线程
const sab = new SharedArrayBuffer(1024 * 1024 * 16);  // 16MB
const sharedView = new Int32Array(sab);

worker1.postMessage({ sab });
worker2.postMessage({ sab });

// Worker 内
self.onmessage = (e) => {
    const sharedView = new Int32Array(e.data.sab);
    // 多个 Worker 操作同一块内存
    Atomics.store(sharedView, 0, 42);
    Atomics.notify(sharedView, 0, 1);  // 唤醒等待
};

WASM 多线程把这个原理用到极致——所有 Worker 实例化同一个 WASM 模块,导入同一个 shared: true 的 WebAssembly.Memory 作为线性内存(它的 buffer 就是一个 SharedArrayBuffer)。开了 +atomics 重建的 std 里,Mutex 等阻塞原语走 futex 实现,底层是 memory.atomic.wait32 / memory.atomic.notify(library/std/src/sys/pal/wasm/atomics/futex.rs),Arc 的引用计数则只是原子读-改-写指令。前提是 atomics 在 Rust 1.96 仍是 unstable 特性、需要 nightly -Zbuild-std(§10.9);而且浏览器主线程上不允许阻塞等待,所以阻塞式加锁只能放在 Worker 里。

11.13.5 通信模式的工程选择

flowchart TD
  A["跨上下文通信需求"] --> B{"数据量?"}
  B -->|小 < 1KB| C["postMessage 直接发"]
  B -->|大 > 1MB| D{"接收方是否要修改?"}
  D -->|是| E["Transferable 转移"]
  D -->|否,多读| F["SharedArrayBuffer 共享"]
  A --> G{"频率?"}
  G -->|< 100/s| H["postMessage 即可"]
  G -->|> 1000/s| I["SharedArrayBuffer + Atomics"]
  style C fill:#10b981,color:#333
  style E fill:#6366f1,color:#fff
  style F fill:#f59e0b,color:#333
  style I fill:#dc2626,color:#fff
场景 推荐 原因
命令分发(小消息) postMessage 简单可靠
大块数据单次处理 Transferable 零拷贝,所有权清晰
流水线数据处理 MessageChannel Worker 直连,主线程不阻塞
多 Worker 共享状态 SharedArrayBuffer 真正零拷贝
高频小消息 SharedArrayBuffer + ring buffer 避免 postMessage 调度开销

11.13.6 实战:流水线模式的实现

视频处理场景:解码 Worker → 滤镜 Worker → 编码 Worker。三个 Worker 用 MessageChannel 串联:

// 主线程:建立两条 channel
const decodeToFilter = new MessageChannel();
const filterToEncode = new MessageChannel();

decoder.postMessage({ output: decodeToFilter.port1 }, [decodeToFilter.port1]);
filter.postMessage({
    input: decodeToFilter.port2,
    output: filterToEncode.port1,
}, [decodeToFilter.port2, filterToEncode.port1]);
encoder.postMessage({ input: filterToEncode.port2 }, [filterToEncode.port2]);

// 主线程:启动流水线
decoder.postMessage({ cmd: 'start', source: videoUrl });  // 只能传可克隆的值,MediaStream 之类不行

// 主线程不参与每帧处理——只负责编排和最终结果消费
encoder.onmessage = (e) => {
    // 收到编码完成的视频帧
};

这套模式让三个 Worker 形成生产-消费流水线,每个 Worker 独立运行,主线程零开销编排。视频帧的传递用 Transferable 保证零拷贝——一个 4K 帧约 32MB,复制成本太高。

11.14 内存与通信的生产监控

§11.9 介绍了内存泄漏的检测——但那是被动响应。生产级 WASM 应用需要主动的内存监控体系,在问题恶化前就发出告警。这套监控基础设施和第 18 章的可观测性结合,构成完整的内存健康保障。

11.14.1 关键内存指标

graph TD
  A["WASM 内存监控指标"] --> B["静态指标"]
  A --> C["动态指标"]
  A --> D["事件指标"]
  B --> B1["当前线性内存大小"]
  B --> B2["历史峰值"]
  B --> B3["分配/释放计数"]
  C --> C1["增长速率<br/>MB/分钟"]
  C --> C2["分配/释放比"]
  C --> C3["平均分配大小"]
  D --> D1["memory.grow 触发"]
  D --> D2["OOM 事件"]
  D --> D3["大分配 > 阈值"]
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333

每类指标的告警阈值(下表是起点示例,没有通用标准,要按自己应用的基线调整):

指标 警告阈值 严重阈值 含义
内存峰值 70% 上限 90% 上限 接近 grow 失败
增长速率 > 1MB/分钟 > 10MB/分钟 可能泄漏
分配/释放比 > 1.1 > 1.5 显著泄漏
memory.grow 频率 > 1/秒 > 10/秒 预分配不足
大分配(> 10MB) 任何一次 频繁 异常请求

11.14.2 浏览器端的监控实现

浏览器端没有 Wasmtime 那样的 ResourceLimiter 钩子(§11.14.3)——线性内存大小可以直接采样,分配计数则要靠 §11.9.2 的 Rust 侧计数分配器导出:

class WasmMemoryMonitor {
    constructor(memory, allocStats, options = {}) {
        this.memory = memory;
        this.allocStats = allocStats;  // §11.9.2 导出的 alloc_stats
        this.metrics = {
            peakBytes: 0,
            growCount: 0,
            allocCount: 0,
            freeCount: 0,
            liveBytes: 0,
        };
        this.intervalId = null;
        this.options = { sampleIntervalMs: 5000, ...options };
    }

    start() {
        this.intervalId = setInterval(() => this.sample(), this.options.sampleIntervalMs);
    }

    sample() {
        const currentBytes = this.memory.buffer.byteLength;
        if (currentBytes > this.metrics.peakBytes) {
            if (this.metrics.peakBytes > 0) this.metrics.growCount++;
            this.metrics.peakBytes = currentBytes;  // 线性内存只增不减,峰值即当前值
        }
        const [allocs, frees, live] = this.allocStats();
        this.metrics.allocCount = allocs;
        this.metrics.freeCount = frees;
        this.metrics.liveBytes = live;
        this.report({ currentBytes, ...this.metrics });
    }

    report(snapshot) {
        if (snapshot.allocCount - snapshot.freeCount > 10000) {
            console.warn('Possible memory leak: alloc/free imbalance');
        }
        if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
            navigator.sendBeacon('/api/wasm-memory', JSON.stringify(snapshot));
        }
    }

    stop() { clearInterval(this.intervalId); }
}

// 用法(--target web):import init, { alloc_stats } from './pkg/app.js';
// init() 返回的就是实例的 exports
const exports = await init();
const monitor = new WasmMemoryMonitor(exports.memory, alloc_stats);
monitor.start();

计数分配器只在每次分配上多几条普通的整数加减(未开 atomics 时 fetch_add 就是普通读-改-写),开销很小;采样本身是定时的一次跨边界调用。需要按调用栈记录分配时(§11.9.2 末尾的做法)开销才明显,建议只在 staging 启用。

11.14.3 服务器端的 Wasmtime 监控

Wasmtime 在每次线性内存/表增长前回调宿主的 ResourceLimiter,可以借它记录增长事件:

use wasmtime::{Store, ResourceLimiter};

// ResourceLimiter 挂在 Store 的 data 上,所以 data 类型要能提供它
struct LoggingLimiter;

impl ResourceLimiter for LoggingLimiter {
    // 注意:current / desired 的单位是**字节**,maximum 为 None 表示无上限
    fn memory_growing(
        &mut self,
        current: usize,
        desired: usize,
        maximum: Option<usize>,
    ) -> wasmtime::Result<bool> {
        log::info!("memory grow: {current} → {desired} (max: {maximum:?})");
        Ok(true) // 返回 false 会让 memory.grow 返回 -1;返回 Err 则表现为 trap
    }

    // table_growing 的 current / desired 单位是**元素个数**,三个参数同样都是 usize
    fn table_growing(
        &mut self,
        _current: usize,
        _desired: usize,
        _maximum: Option<usize>,
    ) -> wasmtime::Result<bool> {
        Ok(true)
    }
}

let mut store = Store::new(&engine, LoggingLimiter);
store.limiter(|state| state as &mut dyn ResourceLimiter);

// 单次查询当前大小
let memory = instance.get_memory(&mut store, "memory").unwrap();
let current_bytes = memory.data_size(&store);
let pages = current_bytes / 65536;

trait 还提供了 memory_grow_failed / table_grow_failed 两个带默认实现的钩子——memory_growing 放行之后增长仍然失败(超过 maximum,或操作系统分配不到内存)时才会调用(见 trait 文档),用来抓"到底是谁申请了这么多内存"的现场非常好用。

把 grow 事件发送到 Prometheus:

fn memory_growing(&mut self, _current: usize, desired: usize, _: Option<usize>) -> wasmtime::Result<bool> {
    metrics::counter!("wasm_memory_grow_total").increment(1);
    metrics::gauge!("wasm_memory_bytes").set(desired as f64);
    Ok(true)
}

11.14.4 异常检测算法

flowchart TD
  A["内存监控数据"] --> B["计算移动平均"]
  B --> C{"当前值 > 7 天均值 × 2?"}
  C -->|是| D["触发异常告警"]
  C -->|否| E{"持续增长 > 30 分钟?"}
  E -->|是| F["可能内存泄漏"]
  E -->|否| G["正常"]
  D --> H["截图当前堆栈<br/>+ 通知值班"]
  F --> I["生成 heap dump<br/>+ 自动诊断"]
  style D fill:#dc2626,color:#fff
  style F fill:#f59e0b,color:#333
  style G fill:#10b981,color:#333

规则:

  • 峰值偏离:当前内存 > 历史 7 天均值 2 倍 → 异常请求或攻击
  • 持续增长:30 分钟连续增长 → 内存泄漏
  • OOM 接近:内存 > 80% 上限 → 提前扩容或拒绝新请求

11.14.5 内存看板的关键面板

Grafana 看板必有的面板:

面板 显示 价值
当前内存 vs 上限 实时百分比 一眼看到是否接近 OOM
内存趋势(24h) 时间序列 发现增长模式
内存峰值(7d) 每日柱状 判断是否需要扩容
memory.grow 事件 事件标记 关联负载尖峰
分配/释放比 实时比例 早期发现泄漏
Top 大分配(按调用栈) 表格 定位热点

这套监控让 WASM 内存问题"在用户感知前被发现"——比反应式的"用户报错才查"成熟得多。

11.15 跨边界数据序列化的工程模式

复杂数据结构跨 JS-WASM 边界传递时,必须序列化为字节。选错序列化格式会让性能从"勉强够用"变"严重瓶颈"——大数据结构上,不同方案的编解码耗时可以差出一个数量级以上。

11.15.1 序列化格式对比

graph TD
  A["跨边界序列化方案"] --> B["JSON"]
  A --> C["MessagePack"]
  A --> D["Protobuf"]
  A --> E["FlatBuffers"]
  A --> F["Bincode"]
  A --> G["原始字节布局"]
  B --> B1["人类可读<br/>慢、大"]
  C --> C1["二进制紧凑<br/>中速"]
  D --> D1["跨语言<br/>schema"]
  E --> E1["零拷贝读<br/>schema"]
  F --> F1["Rust 原生<br/>仅 Rust"]
  G --> G1["完全自定义<br/>极快但脆弱"]
  style B fill:#dc2626,color:#fff
  style E fill:#10b981,color:#333
  style G fill:#10b981,color:#333

11.15.2 性能与体积对比

下表只给定性关系(本章的测量环境没有对这些格式做过可复现的对比,具体数字请在自己的数据上测):

方案 编码成本 解码成本 体积 wasm-bindgen 集成
JSON(serde_json + JSON.parse) 高 高 最大 最简单
MessagePack 中 中 比 JSON 小 rmp-serde + @msgpack/msgpack
Protobuf 中 中 小 prost + JS 侧 protobuf 库
FlatBuffers 构造时付出 近乎为零(按需读字段) 中(带 vtable 与对齐填充) flatc + flatbuffers crate
Bincode 低 低 小 仅 Rust ↔ Rust
直接内存视图 无 无 等于原始布局 手写 JS

JSON 慢且大——但开发体验最好。FlatBuffers 在性能敏感场景明显领先——零拷贝读是关键。

11.15.3 模式一:JSON / serde-wasm-bindgen(开发友好)

use serde_wasm_bindgen::{to_value, from_value};

#[wasm_bindgen]
pub fn process(input: JsValue) -> Result<JsValue, JsValue> {
    let data: MyStruct = from_value(input)?;
    let result = process_internal(&data);
    Ok(to_value(&result)?)
}

严格说,这段代码走的不是 JSON:serde-wasm-bindgen 直接在 Rust 结构和 JS 对象之间转换,逐字段跨边界构造,不经过 JSON 字符串。真正的 JSON 路线是 JS 侧 JSON.stringify、Rust 侧 serde_json::from_str(返回时反过来)。两者开发体验相近,孰快孰慢取决于数据形状,需要实测。

适用:低频调用、数据量不大、调试友好优先。

11.15.4 模式二:MessagePack(平衡选择)

#[wasm_bindgen]
pub fn process_mp(input: &[u8]) -> Result<Vec<u8>, JsValue> {
    let data: MyStruct = rmp_serde::from_slice(input)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    let result = process_internal(&data);
    // 用 to_vec_named:rmp-serde 默认(to_vec / Serializer::new)把结构体编码成
    // 不带字段名的数组,JS 侧 decode 出来是 [..] 而不是 { .. }
    let buf = rmp_serde::to_vec_named(&result)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    Ok(buf)
}

JS 侧用 @msgpack/msgpack 包:

import { encode, decode } from '@msgpack/msgpack';

const input = encode(data);
const output = wasm.process_mp(input);
const result = decode(output);

通常比 JSON 更快、更小(幅度取决于数据,数字多、字符串少时差距更明显)。开发体验比 JSON 略差但可接受。

11.15.5 模式三:FlatBuffers(极致性能)

FlatBuffers 的关键是零拷贝读取——序列化的字节直接是内存布局,反序列化只需要构造 view:

// 用 .fbs schema 生成代码
// schema:
//   table User { name: string; age: int; }

let mut builder = FlatBufferBuilder::new();
let name = builder.create_string("Alice");
let user = User::create(&mut builder, &UserArgs { name: Some(name), age: 30 });
builder.finish(user, None);
let bytes = builder.finished_data();

JS 侧(也用 flatbuffers):

import { ByteBuffer } from 'flatbuffers';
import { User } from './schema_generated';

const buf = new ByteBuffer(bytes);
const user = User.getRootAsUser(buf);
console.log(user.name(), user.age());  // 直接读,无 parse 步骤

这里的"零拷贝"是指没有独立的反序列化阶段:字节布局即内存布局,读一个字段就是按 vtable 里的偏移做一次寻址。所以打开一个 100MB 的 buffer 是常数时间,但代价转移到了每次字段访问上——如果最终要遍历全部数据,总成本并不会凭空消失,只是从"先全解析一遍"变成了"按需摊销"。

适用:性能极致敏感、数据结构相对稳定(schema 修改成本高)、跨语言。

11.15.6 模式四:原始字节布局(极致性能)

最快的方式:JS 和 WASM 共享一个紧凑的内存布局,不用任何序列化框架:

#[repr(C)]
struct Vec3 { x: f32, y: f32, z: f32 }

#[wasm_bindgen]
pub fn process_vec3_array(ptr: *const Vec3, len: usize) {
    let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
    // 直接处理
}

JS 侧:

const vec3Size = 12;  // 3 * 4 bytes
const data = new ArrayBuffer(count * vec3Size);
const view = new DataView(data);

// 写入数据
for (let i = 0; i < count; i++) {
    view.setFloat32(i * vec3Size + 0, x, true);
    view.setFloat32(i * vec3Size + 4, y, true);
    view.setFloat32(i * vec3Size + 8, z, true);
}

// 复制到 WASM 内存
const ptr = wasm.alloc(data.byteLength);  // 自定义导出,须按 4 字节对齐分配:from_raw_parts 要求 ptr 满足 Vec3 的对齐
new Uint8Array(wasm.memory.buffer, ptr, data.byteLength).set(new Uint8Array(data));
wasm.process_vec3_array(ptr, count);

代价:手写胶水代码、易错、不能改 layout(任何字段顺序变化破坏二进制兼容)。

11.15.7 选择决策

flowchart TD
  A["跨边界数据传递"] --> B{"频率?"}
  B -->|低频 < 10/s| C["JSON"]
  B -->|高频 > 100/s| D{"数据复杂?"}
  D -->|简单结构| E["原始字节布局"]
  D -->|复杂嵌套| F{"跨语言?"}
  F -->|仅 Rust↔JS| G{"性能要求?"}
  F -->|多语言| H["Protobuf"]
  G -->|极致| I["FlatBuffers"]
  G -->|平衡| J["MessagePack"]
  style C fill:#10b981,color:#333
  style I fill:#10b981,color:#333
  style E fill:#10b981,color:#333

11.15.8 演进路径

不要一开始就追求最优——按业务发展演进:

flowchart LR
  A["原型阶段<br/>JSON"] --> B["业务稳定<br/>测出瓶颈"]
  B --> C{"瓶颈在序列化?"}
  C -->|是| D["MessagePack 升级"]
  C -->|否| E["保持 JSON"]
  D --> F{"还有瓶颈?"}
  F -->|是| G["FlatBuffers 重构"]
  F -->|否| E
  style A fill:#10b981,color:#333
  style G fill:#f59e0b,color:#333

JSON 是多数项目的合理起点——简单可靠。只在性能确实不够时才升级到二进制格式。过早优化是常见反模式。

11.15.9 错误处理与版本兼容

序列化必须考虑数据版本演进:

维度 JSON MessagePack FlatBuffers Bincode
加字段向后兼容 ✓ ✓ ✓(schema 加字段) ✗
删字段向后兼容 △ △ ✓(标记 deprecated) ✗
字段重命名 △(需 #[serde(alias)]) △(按字段名编码时同 JSON) ✓(二进制里不存字段名) ✓(按位置编码)
类型变更 ✗ ✗ ✗ ✗

JSON/MessagePack 在 schema 演进上最灵活——通过 serde 的 #[serde(default)] 等属性优雅处理新旧版本。Bincode 不支持 schema 演进——版本不匹配直接 fail。这影响选型——业务变化频繁的场景应避免 Bincode。

11.16 内存模型的演进:MVP → 组件模型 → GC

WASM 的内存模型不是固定的——从 MVP 开始已经经历多轮演进。理解演进路线有助于做长期技术规划,避免在过时模型上深度投资。

11.16.1 三代内存模型的演进

graph LR
  A["MVP (2017)"] --> B["扩展提案 (2020-2024)"]
  B --> C["组件模型<br/>(CG 提案,WASI 0.2 于 2024 发布)"]
  B --> D["GC 类型<br/>(2024 标准化,随 Wasm 3.0 发布)"]
  A --> A1["单线性内存<br/>i32/i64/f32/f64"]
  B --> B1["+SIMD<br/>+threads<br/>+multi-memory"]
  C --> C1["+resource<br/>+canonical ABI"]
  D --> D1["+原生 GC<br/>跨语言对象"]
  style A fill:#dc2626,color:#fff
  style B fill:#f59e0b,color:#333
  style C fill:#10b981,color:#333
  style D fill:#6366f1,color:#fff

每一代都解决前一代的根本约束:

  • MVP:建立基础,但单内存 + 仅基础类型限制大
  • 扩展提案:补上 SIMD/线程/多内存,扩展能力但仍是底层
  • 组件模型:高级类型系统跨边界,多语言协作
  • GC 类型:原生 GC,让 WASM 真正适合 Java/C# 等 GC 语言

11.16.2 当前状态:组件模型的影响

组件模型对内存模型的核心改变:

graph TD
  A["MVP 模型"] --> B["每个模块独立线性内存"]
  C["组件模型"] --> D["可选用 record/list/string"]
  D --> E["资源(resource)<br/>跨组件传递"]
  style A fill:#f59e0b,color:#333
  style C fill:#10b981,color:#333

工程上的影响:

维度 MVP 组件模型
数据传递 字节 + 手动布局 类型化 + 自动 lift/lower
跨组件状态 通过线性内存 通过 resource 句柄
多语言互操作 各自约定 Canonical ABI 标准
类型安全 弱 强

但组件模型不是免费的——Canonical ABI 的 lift/lower 要在两侧线性内存之间做真实的编解码和复制(第 14 章会逐步拆解这个过程),复杂类型的成本与数据结构大小成正比。性能极致敏感、且两侧本来就在同一个线性内存里的场景,仍然是 MVP 风格的"原始字节传递"更快。

11.16.3 GC 类型提案的影响

GC 提案让 WASM 能处理"宿主管理的对象"。它已经在 2024 年标准化、随 Wasm 3.0 于 2025 年发布,Chrome 119、Firefox 120、Safari 18.2 起支持(webassembly.org features.json,2026-09-24 核对);但它主要服务 Java/Kotlin/Dart 这类自带 GC 的语言,Rust 的内存仍在线性内存里自己管理。下面用伪代码示意两种模型的差别(Rust 并没有 gc::new 这样的 API):

// 当前(MVP):WASM 模块自己管理对象
let user = User { name: alloc_string("Alice"), age: 30 };
// 必须手动 free,否则线性内存增长

// GC 类型(示意):宿主 GC 管理
let user = gc::new::<User>(User { name: gc_string("Alice"), age: 30 });
// 宿主 GC 自动回收

GC types 对内存模型的根本影响:

  • WASM 内消失分配器:dlmalloc / wee_alloc 不再必需
  • 跨语言对象共享:Java/C# 编译的 WASM 模块可以高效互操作
  • 体积减小:不再需要把分配器链接进模块(以 dlmalloc 为例,第 9 章实测其自身函数约 7.1KB)

但 GC types 也带来新约束:

  • GC 暂停:宿主 GC 可能在执行中暂停 WASM
  • 指针不可见:WASM 代码不能直接操作 GC 对象的内存地址
  • 跨实例隔离更复杂:GC 对象的生命周期跨越多实例

11.16.4 演进对工程的影响

flowchart TD
  A["技术决策的时间维度"] --> B["短期 0-12 月"]
  A --> C["中期 1-3 年"]
  A --> D["长期 3-5 年"]
  B --> B1["MVP + 扩展提案<br/>稳定主流"]
  C --> C1["组件模型主流<br/>逐步迁移"]
  D --> D1["GC 语言生态扩展<br/>(Java/Kotlin/Dart)"]
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333

实际项目应该按时间维度规划:

  • 当前:用 MVP + bulk-memory + multivalue 等成熟提案
  • 2026-2028:考虑用组件模型重构跨服务接口
  • GC 类型:浏览器已普遍支持,但对 Rust 项目没有直接的迁移价值;它影响的是你要不要和 GC 语言编译出的模块打交道

不要为了"用最新技术"而追新——评估业务收益和迁移成本是工程纪律。

11.17 内存 profiling 工具链

§11.9/§11.12/§11.14 介绍了内存泄漏的检测——但深度的内存 profiling(看每个分配的大小、生命周期、调用栈)需要专门工具。这一节整理 WASM 内存 profiling 的工具链。

11.17.1 WASM 内存 profiling 的目标

graph TD
  A["内存 profiling 目标"] --> B["定位泄漏"]
  A --> C["识别峰值"]
  A --> D["优化局部性"]
  A --> E["减少分配"]
  B --> B1["哪些对象没释放"]
  C --> C1["何时占用最多"]
  D --> D1["缓存命中率分析"]
  E --> E1["减少 malloc/free 调用"]
  style A fill:#10b981,color:#333

每个目标需要不同工具——没有"万能 profiler"。

11.17.2 浏览器端的工具链

graph LR
  A["浏览器内存 profiling"] --> B["Chrome DevTools<br/>Memory 面板"]
  A --> C["wasm-bindgen<br/>+ console.profile"]
  A --> D["Rust 侧计数分配器<br/>(§11.9.2)"]
  B --> B1["堆快照对比"]
  C --> C1["函数级 timing"]
  D --> D1["分配级 trace"]
  style A fill:#6366f1,color:#fff

Chrome DevTools 的"Memory 面板"主要看 JS 堆——但 WASM 的线性内存作为 ArrayBuffer 出现在快照中。对比两次快照能看到 ArrayBuffer 增长。

详细的分配级 trace 不能靠在 JS 里替换 __wbindgen_malloc(导出对象是冻结的,见 §11.9.2),要在 Rust 的 #[global_allocator] 里做:§11.9.2 的计数分配器再加上"超过阈值就调用一个导入的 JS 函数、在那边记录 new Error().stack",就能看到每个大分配的来源。

11.17.3 服务器端的工具链

graph TD
  A["服务器 WASM 内存 profiling"] --> B["Wasmtime metrics"]
  A --> C["pprof 集成"]
  A --> D["heaptrack"]
  A --> E["自定义 ResourceLimiter"]
  B --> B1["内存增长指标"]
  C --> C1["CPU + 内存 profile"]
  D --> D1["分配 timeline"]
  E --> E1["精细控制"]
  style A fill:#6366f1,color:#fff

Wasmtime 的 Config::profiler 能把 JIT 代码的符号信息交给 Linux perf(ProfilingStrategy 在 44 里有 None/PerfMap/JitDump/VTune/Pulley 五种),这是 CPU 采样,用来找热点函数:

let mut config = wasmtime::Config::new();
config.profiler(wasmtime::ProfilingStrategy::JitDump);  // 也支持 perfmap

// 跑 WASM 后用 perf 分析
// perf record -k mono -e cycles ./my_wasi_app
// perf script | inferno-collapse-perf | inferno-flamegraph > perf.svg

生成的火焰图是 WASM 函数级别的 CPU 热点,不包含内存数据;guest 的堆分配要靠 §11.14.3 的 ResourceLimiter(只看得到线性内存增长)或 guest 内部的分配器统计。

11.17.4 Rust 端的工具

Rust 自身有几个内存 profiling 工具——某些在 WASM 上工作:

工具 适用 功能
dhat wasm32-wasip1(unknown-unknown 需改造,见下) 堆 profiler
tikv-jemallocator 不支持 wasm32 分配器统计
cap 包装器 wasm32 包装另一个分配器,统计并限制用量
tracing-allocations 实验(0.1.0) 每次分配/释放发一个 tracing 事件

dhat 是 Valgrind DHAT 的 Rust 移植——它以全局分配器的形式记录每一次分配的大小、生命周期和调用栈:

#[cfg(feature = "dhat-heap")]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;

fn main() {
    #[cfg(feature = "dhat-heap")]
    let _profiler = dhat::Profiler::new_heap();

    // 业务代码 ...
}

输出 dhat-heap.json,用 DHAT 的在线 viewer 打开就能看到每个分配点的来源。注意它依赖 Profiler 在 drop 时把文件写到磁盘——在 wasm32-unknown-unknown 上没有文件系统,要么走 wasm32-wasip1 在 Wasmtime 里跑,要么自己改造成把 JSON 通过导入函数吐给宿主。

11.17.5 实战:诊断真实泄漏

flowchart TD
  A["发现内存增长"] --> B["1. 浏览器 RUM 报警"]
  B --> C["2. Chrome 录两次快照对比"]
  C --> D{"哪些对象增长?"}
  D -->|"WASM ArrayBuffer"| E["WASM 侧泄漏"]
  D -->|"JS Object"| F["JS 持有 WASM 对象"]
  E --> G["计数分配器找根因"]
  F --> H["FinalizationRegistry 检查"]
  style G fill:#10b981,color:#333
  style H fill:#10b981,color:#333

完整诊断流程:

  1. 监控发现内存上升
  2. DevTools 拍快照对比
  3. 定位增长对象
  4. 用计数分配器 / dhat 找具体分配点
  5. 修复后验证

11.17.6 自动化 profiling

# CI 中自动跑 profiling
- name: Run benchmark with dhat
  run: cargo bench --features dhat-heap

- name: Compare memory profile
  run: |
    # dhat-heap.json 没有总量字段,按分配点(pps)的 tb 求和
    if [ "$(jq '[.pps[].tb] | add' dhat-heap.json)" -gt 100000000 ]; then
      echo "Memory regression: > 100MB"
      exit 1
    fi

把内存基准放进 CI——任何 PR 显著增加内存使用时立即可见,而不是等上线后用户报问题。

11.18 跨专栏关联:与 Tokio Channel 通信的对照

WASM 的 Guest-Host 通信和《Tokio 源码深度解析》专栏第 13 章描述的 channel 通信有结构性的对应——都是"如何在两个隔离的执行域之间高效传递数据":

维度 WASM Guest-Host Tokio Channel
隔离边界 JS 引擎 ↔ WASM 引擎 Task A ↔ Task B
共享媒介 线性内存(ArrayBuffer) Channel buffer
零拷贝 SharedArrayBuffer / 指针传递 tokio::sync::watch(共享引用)
批量传递 Vec/String 跨边界复制 mpsc::channel 批量 send
同步机制 默认单线程,不需要锁(共享内存时用 Atomics) Mutex / Atomic
背压 无(WASM 同步执行) mpsc::channel 的 bounded buffer

核心差异:Tokio 的 channel 通信在同一个进程的地址空间内——两个 task 可以直接共享内存。WASM 的 Guest-Host 通信跨越了 JS 引擎和 WASM 引擎的边界——共享的只有线性内存。这使得 WASM 的零拷贝更简单(直接操作 ArrayBuffer),但也更危险(没有编译器的类型检查保护)。

另一个重要的对照点:Tokio 的 broadcast channel 支持"多个消费者订阅同一个消息"——这类似于 SharedArrayBuffer 的"多个线程读取同一块内存"。但 Tokio 的 broadcast 是"存一份、按需 clone"——值在 channel 里只存一次,每个接收者 recv 时各自 clone 一份(见 tokio broadcast 模块的文档),跟不上的接收者还会收到 Lagged、丢掉被覆盖的消息;而 SharedArrayBuffer 是真正的零拷贝——没有复制,只有共享。

下一章进入第四部分——WASM 如何走出浏览器,WASI 系统接口如何定义"安全的能力集"。