Rust + WebAssembly 全链路解析
第11章 内存管理与 Guest-Host 通信开销
"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
这个设计的关键含义:
- 所有数据传递最终都是内存操作——无论是通过 wasm-bindgen 的类型转换还是手动指针操作,数据必须写入线性内存或从线性内存读出
- JS 和 WASM 都能读写同一块内存——默认情况下两者跑在同一个线程上、轮流执行,不存在数据竞争;只有用 Web Worker + 共享内存(
SharedArrayBuffer)时才需要同步机制 - 线性内存的增长是不可逆的——
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." 它的核心数据结构:
- 空闲链表(free list):按大小分桶(bin),每个桶是一个双向链表,链接大小相近的空闲块
- 小块缓存(smallbin):小请求按固定间距分桶,命中对应桶就是摘链表头——常数时间
- 大块分配(treebin):大请求走一棵按块大小组织的位数字树(trie),做近似 best-fit——查找是对数级而不是线性扫描整条空闲链
- 顶部块(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") 时发生的事情:
- 胶水函数
passStringToWasm0(arg, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc)先按字符串长度调用__wbindgen_malloc(len, 1)(注意签名是(size, align)两个参数) - 然后走 ASCII 快路径:逐字符检查
charCodeAt(offset) <= 0x7F,直接把码点当字节写进线性内存——纯 ASCII 字符串根本不会调用TextEncoder - 一旦遇到非 ASCII 字符,才
__wbindgen_realloc扩容到offset + 剩余长度 * 3(UTF-8 最坏膨胀率),用TextEncoder.encodeInto就地编码,最后再 realloc 收缩到实际长度 - Rust 的
String从该内存地址构造 - Rust 的
format!生成"Hello, world!"(13 字节),分配新的 WASM 内存 - 返回时
getStringFromWasm0(ptr, len)用模块级缓存的TextDecoder解码线性内存的 subarray - 两块内存分别由不同的一方释放(下面详述)
第 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),而不是两次。
指针传递的风险:
- 悬垂指针:如果 WASM 侧在 JS 还在使用指针时释放了内存,JS 读到的是已被重分配的垃圾数据。必须在 JS 侧确保不再使用指针后才调用
free。 memory.grow后视图失效:如果 WASM 侧的任何操作触发了memory.grow,之前创建的Uint8Array视图会失效(因为底层的ArrayBuffer被替换了)。必须在memory.grow后重新获取视图。- 类型安全:
*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);
}
流程是:
- Rust 的
Vec<u8>通过多值返回把(指针,长度)交给 JS getArrayU8FromWasm0先在线性内存上开一个subarray视图——这一步确实零拷贝- 紧接着的
.slice()把它复制成一个独立的Uint8Array——这一步是复制 __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 常见的泄漏模式
-
忘记调用
free():JS 侧持有 Rust 对象的包装类,但没有调用free()方法。Rust 侧的内存只能等胶水里的FinalizationRegistry在 GC 之后兜底释放(§11.4.3),时机不可控;包装对象若一直可达(比如挂在全局缓存里),就真的永远不会释放。 -
闭包泄漏:
Closure::forget()等价于mem::forget——闭包和它捕获的数据彻底泄漏,谁都回收不了(§7.10)。把回收责任交给 JS GC 的是into_js_value(),但那条路也只在宿主支持弱引用时才真的回收。 -
循环引用:Rust 对象持有
JsValue,JS 对象持有 Rust 对象的指针——两个方向的引用阻止了 GC 回收。 -
全局 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 预防策略
- RAII 模式:在 Rust 侧用
Drop实现自动清理,尽量不把需要手动free()的对象暴露给 JS。胶水自带的FinalizationRegistry只是兜底(§11.4.3),不要把及时释放寄托在它上面。 - 所有权的文档化:在 API 文档中明确说明哪个方向负责释放内存。
Closure的生命周期管理:优先使用Closure::once而非Closure::wrap——一次性闭包的内存管理更简单。- 定期压力测试:在循环中反复创建/销毁对象,观察线性内存是否稳定。
- 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,最终崩溃。
定位流程:
- Chrome Memory 面板拍快照——发现
WebAssembly.Memory.buffer持续增长,每次切换 +200KB - 用 §11.9.2 的 Rust 侧分配统计确认——每次切换都有一批分配再也没有释放,大小与闭包捕获的状态吻合
- 检查代码——每次进入页面都用
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。
定位流程:
- 监控线性内存——确认增长在 WASM 侧
- 用
twiggy看不出问题(静态体积没变) - 在 WASM 侧加入诊断函数——每分钟报告全局静态变量的大小
- 发现一个
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 图像处理组件,前几十张图都正常,之后突然开始输出全黑的图。没有报错,没有异常,控制台干干净净。
定位流程:
- 在 JS 侧打印读回的字节数组——全是
undefined - 打印视图的
byteLength——0 - 打印
view.buffer === wasm.memory.buffer——false - 真正原因: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
完整诊断流程:
- 监控发现内存上升
- DevTools 拍快照对比
- 定位增长对象
- 用计数分配器 / dhat 找具体分配点
- 修复后验证
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 系统接口如何定义"安全的能力集"。