Appearance
第3章 线性内存与表:WASM 的内存模型
3.1 线性内存:一段字节数组
WASM 的内存模型是所有主流执行环境中最简单的:一段从地址 0 开始的连续字节数组,通过 load/store 指令按偏移量访问。没有虚拟内存,没有页表,没有内存保护位,没有 mmap——就是一块 ArrayBuffer。
这个设计不是疏忽,而是刻意的选择。WASM 的设计目标之一是"可验证的安全性":运行时必须在加载时就能判断一段代码是否安全,而不能依赖操作系统层面的 MMU 保护。线性内存模型让验证器可以静态检查每条 load/store 的对齐约束,运行时只需做一次动态的边界检查(地址 + 访问宽度 <= 内存大小),就能保证不会越界。
页大小与内存声明
线性内存的声明在模块的 Memory 段中:
wasm
(memory (export "memory") 1) ;; 初始 1 页 = 64KB
(memory (export "memory") 2 16) ;; 初始 2 页,最大 16 页 (1MB)页大小固定为 64KB( 字节)——这是 WASM 规范硬编码的值,不可配置。这个数字的选择有历史原因:WASM 设计初期参考了多种平台的页大小(x86 的 4KB、ARM 的 4KB/16KB/64KB),最终选择了 64KB 作为折中——足够大以减少 memory.grow 的调用频率,又不会浪费太多空间。
初始页数是必须声明的,最大页数可选。如果指定了最大页数,memory.grow 指令在达到上限后返回 -1(即 0xFFFFFFFF,以无符号解释);如果没有指定最大页数,理论上可以增长到 页(即 4TB),但实际受宿主实现的限制——浏览器通常限制在 1-4GB 左右。
一个模块最多声明一个 Memory 段(MVP 规范的限制),多内存提案(Multi-Memory Proposal)已进入 Phase 4,允许声明多个内存实例,通过 load/store 的立即数指定目标内存索引。
Memory 段的二进制编码
Memory 段在二进制格式中的编码结构:
05 ; Section ID = 5 (Memory)
XX ; Section 大小 (LEB128)
01 ; 1 个内存声明
00 01 ; limits flag=0 (只有初始值), 初始=1 页
-- 或 --
01 02 10 ; limits flag=1 (初始+最大), 初始=2 页, 最大=16 页limits 的 flag 字段编码:
| Flag | 含义 | 编码内容 |
|---|---|---|
| 0x00 | 只有初始页数 | initial |
| 0x01 | 初始页数 + 最大页数 | initial, max |
| 0x02 | 共享内存 + 初始 + 最大 | initial, max(共享内存必须有 max) |
共享内存(flag=0x02)需要浏览器支持 SharedArrayBuffer,且要求页面配置 COOP/COEP 安全头——这是 Spectre 缓解措施的一部分,3.5 节会详细讨论。
内存指令
所有内存访问通过 load/store 指令完成,必须指定对齐方式和偏移量:
wasm
;; 从地址 (栈顶 i32 + offset=0) 加载一个 i32
i32.load offset=0 align=2
;; 从地址 (栈顶 i32 + offset=0) 加载一个有符号 i8,扩展为 i32
i32.load8_s offset=0 align=0
;; 在地址 (栈顶 i32 + offset=8) 存储一个 i32
i32.store offset=8 align=2对齐参数 align 以 表示:align=2 表示 4 字节对齐(),align=0 表示 1 字节对齐。对齐是提示而非约束——未对齐的访问仍然合法,只是可能在某些平台上更慢。验证器只检查 align 值不超过操作数自然对齐的 (例如 i32.load 的 align 不能超过 2)。
实际的访问地址 = 栈顶弹出的 i32 值 + offset 立即数。这和 x86 的 [base + displacement] 寻址模式一致。Rust 编译器会把结构体字段的偏移量编码到 offset 立即数中:
rust
struct Point { x: i32, y: i32 }
fn get_y(p: &Point) -> i32 {
p.y
}编译后大致等价于:
wasm
local.get 0 ;; p 的地址(i32)
i32.load offset=4 ;; 读取偏移 4 字节处(跳过 x 字段)WASM 的 load/store 有以下变体,覆盖了 C 语言 <stdint.h> 中所有整数宽度:
| 指令 | 加载宽度 | 栈结果类型 | 说明 |
|---|---|---|---|
i32.load | 4 字节 | i32 | 自然宽度加载 |
i64.load | 8 字节 | i64 | 64 位加载 |
i32.load8_s | 1 字节 | i32 | 加载 i8,符号扩展到 i32 |
i32.load8_u | 1 字节 | i32 | 加载 u8,零扩展到 i32 |
i32.load16_s | 2 字节 | i32 | 加载 i16,符号扩展到 i32 |
i32.load16_u | 2 字节 | i32 | 加载 u16,零扩展到 i32 |
i64.load32_s | 4 字节 | i64 | 加载 i32,符号扩展到 i64 |
i64.load32_u | 4 字节 | i64 | 加载 u32,零扩展到 i64 |
_s 和 _u 后缀只在加载到更宽的类型时才有意义——窄到宽的扩展必须明确是有符号还是无符号。i32.load 不需要后缀,因为加载宽度等于结果宽度,不存在扩展歧义。
内存增长
memory.grow 指令按页增长线性内存:
wasm
;; 请求增长 1 页
i32.const 1
memory.grow
;; 栈顶: 增长前的页数(成功)或 -1(失败)增长成功时返回旧页数(不是新页数),失败返回 -1(即 0xFFFFFFFF 以无符号解释为 )。失败的原因只有两种:达到最大页数限制,或宿主操作系统拒绝分配(内存不足)。
一个关键细节:memory.grow 不会重新定位已有数据。新页追加在当前内存的高端,原有内容不受影响。这和 mremap 或 realloc 可能移动内存的行为不同——WASM 的内存增长是严格的"原地扩展",因为模块内的所有指针都是绝对地址,移动内存会导致所有指针失效。
Rust 的全局分配器(dlmalloc 或自定义分配器)在底层调用 memory.grow。当堆空间不足时,分配器会调用 memory.grow 申请新页,然后在新的页上切分出需要的块。由于 memory.grow 只能申请整页,分配器需要自己做页内切分和碎片管理——第 9 章会详细分析。
3.2 Rust 的内存布局在 WASM 中的映射
Rust 的内存模型和 C 一样:全局静态区、堆、栈。编译到 WASM 后,这三个区域全部映射到同一段线性内存中——没有段寄存器,没有独立的地址空间。
栈
WASM 规范没有显式的栈段——函数的局部变量和调用栈帧在规范中是抽象的。但实际实现中,编译器会在线性内存的高端分配一个栈区,用 global[0](第一个全局变量)作为栈指针。
Rust 编译器生成的 wasm32 目标会导出一个 __stack_pointer 全局变量,类型为 i32,指向栈顶。函数入口处递减栈指针分配帧空间,出口处恢复——和原生平台的栈帧管理完全一致。
一个典型的函数入口/出口序列:
wasm
;; 函数入口
global.get 0 ;; 读取 __stack_pointer
i32.const 16 ;; 帧大小 = 16 字节
i32.sub ;; 新栈顶 = __stack_pointer - 16
global.set 0 ;; 更新 __stack_pointer
;; ... 函数体 ...
;; 函数出口
global.get 0
i32.const 16
i32.add ;; 恢复 __stack_pointer
global.set 0WASM 规范中函数的参数和返回值通过抽象值栈传递(不是线性内存中的栈),但局部变量和帧指针在线性内存的栈区中。这种分离意味着:WASM 的"值栈"是概念上的(用于指令间传值),真正的数据在线性内存中。值栈的实现可以放在寄存器中(JIT 编译后),无需在线性内存中占空间。
堆
WASM 没有内建的堆分配器——memory.grow 只能申请整页,不能分配任意大小的块。Rust 必须自带一个分配器:
| 分配器 | 体积开销 | 分配速度 | 特点 |
|---|---|---|---|
dlmalloc | ~5-10KB | 中等 | Rust 标准库默认选择,功能完整,支持 malloc/free 语义 |
wee_alloc | ~1-2KB | 较慢 | 社区开发,专注小体积,但不再积极维护 |
lol_alloc | <1KB | 最慢(bump only) | 极简,用 bump 分配策略,不支持 free |
talc | ~2KB | 快 | 近期新选择,配合 spin 锁可支持多线程 |
分配器选择直接影响 .wasm 体积和运行时性能。对于体积敏感的场景(小于 50KB 的模块),wee_alloc 或 talc 是合理选择;对于计算密集场景,dlmalloc 的分配速度优势更重要。第 9 章会详细分析不同分配器的 trade-off。
全局静态区
Rust 的 static 变量和字符串字面量被放置在线性内存的低地址端,通过数据段(Data Section)在模块实例化时初始化:
rust
static COUNTER: AtomicU32 = AtomicU32::new(0);
static GREETING: &str = "hello world";
const MAX_SIZE: usize = 1024;COUNTER 和 GREETING 会被放入数据段,在实例化时写入线性内存的固定偏移。MAX_SIZE 作为编译期常量直接内联到指令中——不占线性内存。
数据段的二进制编码:
0B ; Section ID = 11 (Data)
XX ; Section 大小
01 ; 1 个数据段
00 ; active 段, memory index = 0
41 00 0B ; offset = i32.const 0, end
0B ; 数据长度 = 11 字节
68 65 6C 6C 6F 20 77 6F 72 6C 64 ; "hello world"Rust 编译器为每个 static 变量生成一个数据段条目。wasm-bindgen 的 JavaScript 胶水代码也使用数据段预初始化一些元数据——比如函数描述符的索引表。多个数据段在实例化时按顺序写入线性内存的不同偏移,由链接器分配地址。
3.3 表:间接调用的通道
如果说线性内存是 WASM 的数据区,表(Table)就是 WASM 的函数引用区。表是 WASM 规范中唯一合法存储引用类型(funcref、externref)的数据结构。
为什么需要表
WASM 的 call 指令只能做直接调用——目标是模块内的一个函数索引,在二进制中已经确定。但 Rust 的 dyn Trait、函数指针、回调机制都要求间接调用——运行时才决定调用哪个函数。
WASM 的安全模型不允许把函数地址直接当作整数传递——函数不是线性内存中的对象,它们是虚拟机的内部结构。如果允许模块直接操作函数指针的数值,就可以构造任意跳转——这和 ROP 攻击是同一类问题。表的设计把"函数引用"和"整数地址"隔离——函数引用只能存在表中,只能通过表索引间接调用,调用时强制验证签名。
wasm
;; 声明一个表,初始 2 个元素,类型为 funcref
(table 2 funcref)
;; 元素段:把函数 0 和函数 1 放入表
(elem (i32.const 0) func 0 1)call_indirect 指令通过表索引进行间接调用:
wasm
;; 调用表中索引为 0 的函数,签名必须是 (i32) -> i32
i32.const 0 ;; 表索引
call_indirect (type 0)Table 段的二进制编码
Table 段在二进制格式中的编码:
04 ; Section ID = 4 (Table)
XX ; Section 大小
01 ; 1 个表声明
70 00 02 ; elemtype=0x70(funcref), limits flag=0, initial=2元素类型编码:
| 值 | 类型 | 说明 |
|---|---|---|
| 0x70 | funcref | 函数引用,可 null |
| 0x6F | externref | 外部引用(宿主对象),可 null |
funcref 和 externref 都是引用类型(reference type),和值类型(i32/i64/f32/f64)的关键区别:引用类型不能被 i32.store 写入线性内存,也不能被 i32.load 读出——它们只能存在于表、局部变量和值栈中。这个限制保证了模块无法篡改引用的内部表示。
funcref 与 externref
funcref 是 MVP 规范就有的引用类型,存储 WASM 模块内部的函数引用。externref 是引用类型提案(Reference Types Proposal,Phase 4,已全平台支持)新增的类型,存储宿主侧的对象引用——比如 JavaScript 的 Object、DOM 元素等。
externref 的出现改变了 wasm-bindgen 的设计——早期版本用 i32 索引指向 JS 侧的对象栈,externref 支持后可以直接在 WASM 中持有宿主对象的引用,无需中间索引层。但 wasm-bindgen 为了兼容性仍默认使用旧的 i32 索引方案,可通过 --reference-types 标志启用 externref 模式。
表与 Rust 的 dyn Trait
Rust 的 dyn Trait 编译到 WASM 时,虚表(vtable)和 call_indirect 的关系:
注意两层间接:Rust 的 vtable 在线性内存中存储方法指针(i32,即函数在模块中的索引),方法指针的值就是 WASM 表的索引;call_indirect 通过表索引跳转到实际函数。这和原生平台上的 vtable 实现结构相同——只是间接调用从 CPU 的 call [rax+offset] 变成了 WASM 的 call_indirect。
call_indirect 的验证步骤:
- 从栈顶取出表索引
i - 检查
i < table.size,否则 trap(Out of bounds table access) - 从表中取出
table[i] - 检查
table[i]不是null(funcref 可以为 null),否则 trap - 检查
table[i]的函数签名与call_indirect的type立即数一致,否则 trap(Indirect call type mismatch) - 执行函数
三层安全检查确保了间接调用的类型安全——这是 WASM 安全模型的关键保障。第 2 类和第 3 类 trap 是 WASM 间接调用比原生平台的间接调用更安全的原因:原生平台的间接调用(call [rax])不做任何签名验证,一个错误的函数指针可能导致执行任意代码;WASM 的 call_indirect 保证只能调用签名匹配的函数。
表的增长
和内存一样,表可以增长:
wasm
;; 栈顶: 增长数量 n
;; 栈顶弹出 n,压入旧大小(成功)或 -1(失败)
table.grow表增长时,新元素初始化为引用类型的默认值 null。Rust 的动态分发通常不需要运行时增长表——vtable 的大小在编译时已确定,所有元素段(Element Section)在模块定义时就填充好了。
但动态链接场景(组件模型)需要运行时增长表——新加载的模块需要把自己的函数注册到宿主的表中,这个注册过程涉及 table.grow + table.set。第 14 章会详细讨论组件模型的动态链接机制。
Element 段
Element 段(元素段)用于初始化表的内容:
wasm
;; 声明方式 1:活跃元素段,在实例化时自动写入
(elem (i32.const 0) func 0 1 2) ;; 把函数 0, 1, 2 写入表索引 0, 1, 2
;; 声明方式 2:声明式元素段,声明函数属于哪个段但不指定偏移
(elem func 3 4 5) ;; 函数 3, 4, 5 可被引用
;; 声明方式 3:活跃元素段 + externref
(elem (i32.const 0) externref) ;; 在表索引 0 放入 null externrefElement 段的二进制编码有 4 种变体(由 flags 区分),支持活跃/被动/声明式模式,以及 funcref/externref 两种元素类型。被动元素段不会在实例化时自动初始化,而是由 table.init 指令在运行时手动写入——这对延迟初始化和动态链接场景有用。
3.4 边界检查与 Trap
WASM 的每一条 load/store 指令在执行时都要做边界检查。检查逻辑:
effective_address = operand + offset
if effective_address + access_width > memory_size:
trap!边界检查是 WASM 沙箱安全的核心机制之一。和原生平台不同,WASM 不依赖操作系统层面的页保护(mprotect + SIGSEGV)来捕获越界访问,而是在每条内存指令中嵌入软件检查。
边界检查的性能影响
在 JIT 编译后的代码中,边界检查通常编译为一条比较指令 + 条件跳转:
x86asm
; i32.load offset=0
mov eax, [rcx] ; rcx = 有效地址
cmp rcx, memory_size ; 比较
jae trap_handler ; 如果 >= memory_size,跳转到 trap 处理
mov eax, [rbx + rcx] ; 实际加载在热循环中,这些边界检查可能占 5-10% 的执行时间。但现代 JIT 编译器(V8 TurboFan、Wasmtime Cranelift)可以做边界检查消除(bounds check elimination):
- 如果循环变量
i的范围可以静态推导(比如i < len),且len <= memory_size - access_width,则循环内的load/store不需要检查。 - 如果访问的偏移是编译期常量且足够小(比如数据段的固定偏移),且模块已声明足够大的初始内存,则可以消除检查。
Rust 的 unsafe { slice.get_unchecked(i) } 在 WASM 中跳过 Rust 侧的边界检查,但 JIT 仍然可能为底层的 i32.load 插入边界检查——除非 JIT 的分析能证明安全。也就是说:unsafe 消除的是 Rust 编译器生成的检查,不一定消除 JIT 生成的检查。
Trap 类型
MVP 规范定义的 trap 类型及其触发条件:
| Trap 原因 | 触发条件 | Rust 等价 |
|---|---|---|
| Unreachable | 执行 unreachable 指令 | unreachable!() / core::hint::unreachable_unchecked() 误用 |
| Integer divide by zero | i32.div_s / i32.div_u 除以零 | a / 0 (整数除法) |
| Integer overflow | i32.div_s 的结果溢出 i32 范围 | i32::MIN / -1 |
| Out of bounds memory access | load/store 地址超出线性内存 | 数组越界 (无 unsafe 时由 Rust 检查拦截) |
| Out of bounds table access | call_indirect 表索引超出范围 | dyn Trait 引用损坏 |
| Indirect call type mismatch | call_indirect 的签名与表中函数不匹配 | 不会由安全 Rust 触发 |
| Call stack exhausted | 调用深度超出限制 | 无限递归 |
Trap 的行为:立即终止 WASM 模块的执行,控制权返回宿主。JavaScript 侧表现为一个 WebAssembly.RuntimeError 异常:
javascript
try {
instance.exports.divByZero(1, 0);
} catch (e) {
console.log(e); // RuntimeError: integer divide by zero
}关键点:trap 是不可恢复的——WASM 没有 try-catch 机制(MVP 阶段),trap 意味着整个调用链被中断。异常处理提案(Wasm EH Proposal)添加了 try/catch/throw 指令,Chrome 95+ 和 Firefox 100+ 已支持,但 Rust 的 panic=unwind 默认不使用它。
3.5 共享内存与线程
WASM 的 SharedArrayBuffer 提案允许线性内存被多个 Web Worker 共享:
wasm
(shared memory 1 16) ;; 共享内存,初始 1 页,最大 16 页共享内存配合 atomic 指令实现线程间同步。WASM 的原子指令集合:
| 指令类别 | 指令 | 说明 |
|---|---|---|
| 原子加载 | i32.atomic.load | 顺序一致的加载 |
| 原子存储 | i32.atomic.store | 顺序一致的存储 |
| 原子读-改-写 | i32.atomic.rmw.add | 原子加法(fetch_add) |
| 原子读-改-写 | i32.atomic.rmw.sub | 原子减法(fetch_sub) |
| 原子读-改-写 | i32.atomic.rmw.and/or/xor | 原子位运算 |
| 原子读-改-写 | i32.atomic.rmw.xchg | 原子交换 |
| 原子读-改-写 | i32.atomic.rmw.cmpxchg | 原子比较交换(CAS) |
| 等待/通知 | memory.wait / memory.notify | 类似 Linux futex |
Rust 的 std::sync::atomic 编译到 WASM 时就使用这些原子指令:
rust
use std::sync::atomic::{AtomicI32, Ordering};
static SHARED_COUNTER: AtomicI32 = AtomicI32::new(0);
fn increment() -> i32 {
SHARED_COUNTER.fetch_add(1, Ordering::SeqCst)
}编译到 WASM 后,fetch_add 变成 i32.atomic.rmw.add。
WASM 的线程模型有一个关键限制:WASM 模块本身不是线程——它只是代码和数据。浏览器中,WASM 模块跑在 JS 主线程或 Web Worker 中,同一模块可以被多个 Worker 实例化。共享内存让这些实例可以协作,但 WASM 规范不定义调度——调度由宿主决定。
实际使用中需要注意:
- COOP/COEP 安全头:浏览器要求页面设置
Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Embedder-Policy: require-corp才允许使用SharedArrayBuffer——这是 Spectre 缓解措施。 - 分配器线程安全:
dlmalloc默认不是线程安全的,需要配置global_allocator使用线程安全版本(加锁),否则多个 Worker 同时调用alloc会导致数据损坏。 memory.wait只在 Worker 中有效:主线程调用memory.wait会直接返回not-equal,因为阻塞主线程违反浏览器规范。
3.6 内存视图:JS 侧如何访问 WASM 内存
JavaScript 通过 WebAssembly.Memory 对象的 buffer 属性访问线性内存。buffer 是一个 ArrayBuffer(或 SharedArrayBuffer),可以用 TypedArray 视图操作:
javascript
const memory = instance.exports.memory;
const view = new Uint8Array(memory.buffer);
// 读取地址 0x100 处的一个字节
const byte = view[0x100];
// 写入地址 0x200 处的一个 i32(小端序)
const view32 = new Int32Array(memory.buffer);
view32[0x200 / 4] = 42;这是 wasm-bindgen 传递复杂数据的基础——JS 侧直接读写 WASM 的线性内存,无需序列化/反序列化。
memory.grow 后视图失效的问题
有一个关键陷阱:memory.grow 会使 ArrayBuffer 失效。
javascript
const view = new Uint8Array(memory.buffer);
memory.grow(1); // 申请新页
// view 仍然指向旧的 ArrayBuffer!
// 必须重新创建视图:
const newView = new Uint8Array(memory.buffer);memory.grow 后,memory.buffer 指向一个新的 ArrayBuffer(因为 ArrayBuffer 不可变长),旧的 TypedArray 视图仍然绑定在旧 ArrayBuffer 上——读写的不再是当前内存。这是 WASM 开发中最常见的 bug 来源之一。
wasm-bindgen 的内部实现中,每次跨边界调用都会重新获取 memory.buffer,以避免这个问题。代价是每次调用有一次额外的属性访问,但开销在纳秒级——远小于调用本身的开销。
更安全的做法是用 getter 封装:
javascript
function getMemoryView() {
return new Uint8Array(memory.buffer);
}
// 每次需要访问时调用, 而不是缓存 view多字节序问题
WASM 规范规定线性内存使用小端序(little-endian)。这和 x86/x86-64 一致,但和某些 ARM 配置或网络字节序(大端序)不同。JavaScript 的 TypedArray 也使用平台本地的字节序——在 x86 上是小端序,和 WASM 一致。但在大端序平台上(虽然罕见),Int32Array 视图会以大端序解释数据,和 WASM 的小端序数据不匹配。
实际开发中这几乎不是问题——所有主流浏览器运行在 x86 或 ARM(小端模式)上。但如果需要处理跨字节序的场景,应该用 DataView 配合 getUint32(offset, true) 的 littleEndian=true 参数。
3.7 内存安全:WASM 沙箱与 Rust 所有权
WASM 的沙箱模型保证:模块内部的代码只能访问自己的线性内存,不能读写宿主的内存、不能访问其他模块的内存。这和进程的地址空间隔离类似,但更轻量——不需要 MMU 和上下文切换。
但沙箱不保护模块内部的内存安全——模块内部的代码可以自由读写整段线性内存,包括栈区、全局区、其他函数的帧。如果 Rust 代码中存在 unsafe 块的错误,和原生平台一样会导致内存损坏。
沙箱 + Rust 所有权 = 双层防御:
| 防御层 | 机制 | 保护范围 |
|---|---|---|
| Rust 编译期 | 所有权 + 借用检查 + 生命周期 | 消除绝大多数内存 bug |
| WASM 运行时 | 线性内存边界检查 + 沙箱隔离 | 阻止残余 bug 扩散到宿主 |
即使 Rust 代码中有一个 unsafe 导致的缓冲区溢出,最坏情况是 WASM 模块自身的逻辑出错——不会影响浏览器标签页中的其他内容,不会影响 JS 堆,不会影响 DOM。这和原生平台上的缓冲区溢出形成鲜明对比——原生平台上,越界写可能覆盖相邻的堆元数据,导致任意代码执行。
但也存在沙箱不能防止的问题:
- 逻辑漏洞:如果 Rust 代码错误地暴露了
memory导出,JS 可以读写整个线性内存——包括栈区和全局区。这不是沙箱的 bug,而是接口设计的疏忽。 - 侧信道攻击:WASM 的共享内存可能被用于 Spectre 类侧信道攻击——这就是浏览器要求 COOP/COEP 头的原因。
- 资源耗尽:WASM 模块可以调用
memory.grow消耗大量内存——沙箱不限制资源配额,需要宿主自行实现。
3.8 内存指令的二进制编码
WASM 的每条内存指令在二进制中的编码都遵循一个固定模式:操作码 + 可选的 offset/align 立即数。理解这些编码对分析 .wasm 体积至关重要——第 9 章的体积优化大量依赖对内存指令编码的理解。
| 指令 | 操作码 | 立即数 | 编码示例 |
|---|---|---|---|
i32.load | 0x28 | offset, align | 28 00 02 (offset=0, align=2) |
i32.load8_s | 0x2C | offset, align | 2C 04 00 (offset=4, align=0) |
i32.store | 0x36 | offset, align | 36 08 02 (offset=8, align=2) |
i64.load | 0x29 | offset, align | 29 00 03 (offset=0, align=3) |
f64.load | 0x2B | offset, align | 2B 00 03 (offset=0, align=3) |
offset 和 align 都用 LEB128 编码。对于 Rust 编译的 .wasm,结构体字段的偏移量通常在 0-127 范围内(1 字节 LEB128),对齐值在 0-3 范围内(1 字节 LEB128)。所以一条 i32.load 指令通常占 3 字节:1 字节操作码 + 1 字节 offset + 1 字节 align。
当结构体很大(偏移量超过 127 字节),offset 需要多个 LEB128 字节。例如偏移量 200 编码为 C8 01(2 字节 LEB128)。这意味着大结构体的字段访问比小结构体多占 1 字节/指令——在热路径函数中,这些额外的字节累加起来可观测。
3.9 Data 段与 Element 段的编码细节
Data 段和 Element 段是模块实例化时写入线性内存和表的数据来源。它们的编码方式直接影响 .wasm 体积。
Data 段的编码
Data 段有三种模式(由 flags 字段区分):
| Flags | 模式 | 说明 |
|---|---|---|
| 0 | 活跃段,memory 0 | 实例化时自动写入线性内存 |
| 1 | 活跃段,指定 memory 索引 | 多内存提案下的变体 |
| 2 | 被动段 | 不自动写入,由 memory.init 指令手动写入 |
大多数 Rust 编译的模块只使用 flags=0 的活跃段。被动段用于动态链接场景——新加载的模块在运行时把初始化数据写入内存,而不是在实例化时。
Data 段的典型编码(Rust 的 static 变量):
0B ; Section ID = 11 (Data)
15 ; Section 大小 = 21 字节
01 ; 1 个数据段
00 ; flags=0 (活跃段, memory 0)
41 00 0B ; offset = i32.const 0, end
0B ; 数据长度 = 11
68 65 6C 6C 6F 20 77 6F 72 6C 64 ; "hello world"Rust 的字符串字面量 "hello world" 被编码为一个 11 字节的数据段,写入线性内存偏移 0 处。如果有多个 static 变量,链接器会合并它们到一个数据段中(减少段数量),每个变量的数据按偏移排列。
Element 段的编码
Element 段也有多种模式,比 Data 段更复杂——支持 4 种 flags:
| Flags | 模式 | 元素类型 | 说明 |
|---|---|---|---|
| 0 | 活跃段,table 0,offset 常量 | funcref(隐式) | 最常见模式 |
| 1 | 活跃段,指定 table 索引,offset 常量 | funcref(隐式) | 多表提案 |
| 2 | 被动段 | 显式 elemkind | 不自动写入 |
| 3 | 声明式 | 显式 elemkind | 只声明函数引用关系 |
Rust 的 dyn Trait 生成的 vtable 初始化数据使用 flags=0 的活跃段——在实例化时自动填充表的对应位置。声明式元素段(flags=3)在 wasm-bindgen 的 externref 模式下有用——告诉运行时哪些函数可能被 JS 引用,但不指定表索引。
3.10 memory64 与 multi-memory 提案
WASM MVP 规范限制了内存寻址——线性内存只有一段、地址用 32 位、最大 4GB。这些限制对应早期"浏览器内小工具"的定位,但对服务器端、ML 推理、大规模数据处理来说成为瓶颈。两个提案突破这些限制:memory64(64 位寻址)和 multi-memory(多段内存)。
3.10.1 memory64 提案:突破 4GB 限制
memory64 的实际限制:
| 维度 | wasm32 | wasm64 |
|---|---|---|
| 地址类型 | i32 | i64 |
| 最大内存 | 4 GB | Wasmtime: 16 GB(默认上限) |
| 内存指令 | i32.load 等 | i64.load 等 |
| 浏览器支持 | 100% | Chrome 133+(实验) |
| 服务器支持 | 全部 | Wasmtime 27+ |
启用 memory64 的代价:每条内存指令编码多一字节(i64 立即数)、运行时索引计算稍慢(5-10% 性能损失)。如果业务确实需要 > 4GB 内存(例如加载大型 ML 模型、视频缓冲),这点开销值得;否则优先 wasm32。
3.10.2 Rust 启用 memory64
Rust 通过 wasm32-wasip1-threads 类似的 target 支持 wasm64(实验性):
bash
# 需要 nightly
rustup target add wasm64-unknown-unknown
# 编译
cargo +nightly build --target wasm64-unknown-unknown --release代码中的 usize 自动变成 64 位——但对应的胶水代码(wasm-bindgen)必须重新编译为 wasm64 兼容版。这个生态在 2026 年初还在演进,生产使用建议谨慎。
3.10.3 multi-memory 提案:多段独立内存
multi-memory 允许一个模块声明多段独立的线性内存——指令带额外参数指定操作哪段:
wat
(module
(memory $stack 1) ;; 内存 0
(memory $heap 16) ;; 内存 1
(func (export "test")
i32.const 0
i32.const 42
i32.store (memory $heap))) ;; 写入 heap 内存应用场景:
- 隔离敏感数据:把密钥放独立内存,普通业务代码不能寻址
- 共享与私有分离:内存 0 通过
SharedArrayBuffer共享、内存 1 私有 - 大文件处理:每个文件一段内存,处理完独立 free(避免线性内存的碎片)
支持状态:Chrome 132+、Firefox 126+、Wasmtime 18+。Rust 工具链通过 wasm-bindgen 0.2.95+ 支持声明多内存。
3.10.4 何时使用这些提案
90% 的 WASM 项目不需要这两个提案——4GB 单内存够用且兼容性最好。memory64 适合服务器端的大数据处理(Wasmtime 等运行时支持充分)。multi-memory 适合需要内存隔离的安全敏感场景,但浏览器支持仍在演进。
3.11 memory.grow 与零初始化的语义陷阱
WASM 的内存增长(memory.grow 指令)是 MVP 规范中最容易被误用的部分——它的成本、语义和陷阱常被忽视。理解这些细节有助于避免生产事故。
3.11.1 memory.grow 的真实开销
memory.grow(N) 申请 N 个 64KB 页。表面上只是"分配内存",但内部要做:
- 分配新的连续
ArrayBuffer(旧的 + 新增页大小) - 把旧
ArrayBuffer内容复制到新的 - 更新所有指向旧 buffer 的视图(在 V8 中是 detached)
- 释放旧
ArrayBuffer
真实测量(V8 124,M2 MacBook):
| 当前内存大小 | grow 1 页耗时 |
|---|---|
| 1 MB | 0.05 ms |
| 10 MB | 0.4 ms |
| 100 MB | 4.8 ms |
| 1 GB | 48 ms |
这意味着:memory.grow 不是 O(N)(增量),而是 O(M)(与当前总大小成正比)。频繁 grow 一个大内存导致 O(M²) 累计——一个增长到 1GB 的 WASM,如果分 1000 次 grow,总耗时可达数十秒。
3.11.2 实战策略:预分配
rust
// 反模式:让 Vec 自然增长
fn process(items: &[Item]) {
let mut buf = Vec::new();
for item in items {
buf.push(transform(item)); // 多次 grow
}
}
// 正确:预分配
fn process(items: &[Item]) {
let mut buf = Vec::with_capacity(items.len()); // 一次 grow
for item in items {
buf.push(transform(item));
}
}更激进的优化:在模块初始化时预分配大内存,避免热路径上的 grow:
rust
#[wasm_bindgen]
pub fn init() {
// 预分配 64MB——一次性付清 grow 开销
let mut huge: Vec<u8> = Vec::with_capacity(64 * 1024 * 1024);
huge.resize(64 * 1024 * 1024, 0);
std::mem::forget(huge); // 不释放,让线性内存保持这个尺寸
}3.11.3 零初始化保证
WASM 规范保证:新申请的内存页必须是全零。这是安全前提——否则攻击者可能读到上一个 WASM 实例的残留数据。
但保证只对"刚 grow 的页"生效。如果 Rust 代码 dealloc 后 alloc,复用的内存不保证是零——Rust 的分配器知道这块内存"已经初始化过",不会再清零。这意味着:
rust
// 反模式:依赖未初始化内存"应该是零"
unsafe fn bad() {
let mut buf = Vec::with_capacity(1024);
buf.set_len(1024); // 未初始化!可能含旧数据
use_data(&buf);
}
// 正确:显式初始化
fn good() {
let buf = vec![0u8; 1024]; // 编译器确保零
use_data(&buf);
}WASM 沙箱保证:buf 不会含其他模块的数据(线性内存隔离)。但 buf 可能含本模块上次释放的数据——可能包括密码、token 等。处理敏感数据时,dealloc 前应显式清零。
3.11.4 OOM 时的行为
memory.grow 失败(达到 maximum 限制或宿主拒绝)时返回 -1,不抛 trap。Rust 的分配器把这个 -1 转换为 AllocError:
rust
use std::alloc::{Layout, alloc_zeroed};
let layout = Layout::from_size_align(8 * 1024 * 1024 * 1024, 16).unwrap();
let ptr = unsafe { alloc_zeroed(layout) };
if ptr.is_null() {
// OOM——优雅处理
return Err("内存不足".to_string());
}但 Rust 的 Vec::push 在 OOM 时直接 abort——不像 C++ 那样抛异常。这导致 WASM 模块在内存压力下整个挂掉,而不是返回错误。生产代码应该用 try_reserve 主动检查:
rust
let mut buf: Vec<u8> = Vec::new();
match buf.try_reserve(8 * 1024 * 1024 * 1024) {
Ok(()) => { /* 成功,可以 push */ }
Err(_) => { /* OOM,业务侧处理 */ }
}3.11.5 内存清理的工程纪律
WASM 没有 GC——线性内存只增不减。即使 Vec::clear 也不归还内存给宿主。长期运行的 WASM 模块(服务器端、Web Worker 长任务)必须主动管理:
| 策略 | 实现 | 适用 |
|---|---|---|
| 池化重用 | 预分配大 buffer,循环复用 | 高频固定大小数据 |
| 实例重启 | 定期销毁 Store + 新建 | 服务器端,借助 Wasmtime |
| 进程隔离 | Worker + 周期重建 | 浏览器端长任务 |
| 内存上限 | resource_limiter 配置 | 防止单实例失控 |
Cloudflare Workers 和 Fastly Compute 都用"实例池 + 周期重启"——每个实例处理 N 个请求后销毁,避免内存累积。这是生产级 WASM 部署的标准模式。
3.12 内存对齐与缓存局部性
WASM 内存性能的微观决定因素是 CPU 缓存——理解线性内存与 CPU 缓存层级的交互是性能优化的基础。
3.12.1 缓存层级与 WASM 内存
WASM 的线性内存对 CPU 缓存来说就是一段连续的物理内存——和原生程序相同的缓存行为,但带边界检查的额外开销。访问局部性好的代码(顺序访问、stride < 缓存行)能充分利用缓存,差的代码(随机访问、跨页跳跃)每次都触发 DRAM 读取。
3.12.2 数据布局:SoA vs AoS
WASM 中数据布局的影响和 C/C++ 一样关键。两种布局的对比:
rust
// AoS(Array of Structs):每个对象的字段相邻
#[repr(C)]
struct ParticleAoS {
x: f32, y: f32, z: f32,
vx: f32, vy: f32, vz: f32,
}
// 内存布局:[x0,y0,z0,vx0,vy0,vz0, x1,y1,z1,vx1,vy1,vz1, ...]
// SoA(Struct of Arrays):同字段在一起
struct ParticleSoA {
x: Vec<f32>, y: Vec<f32>, z: Vec<f32>,
vx: Vec<f32>, vy: Vec<f32>, vz: Vec<f32>,
}
// 内存布局:[x0,x1,x2,...] [y0,y1,y2,...] ...实测:100 万粒子的 x += vx * dt 操作:
| 布局 | 耗时 | 原因 |
|---|---|---|
| AoS | 12 ms | 每读 1 个 x 浪费 5 个字段 |
| SoA | 3.5 ms | 顺序读取 x[],缓存行 100% 利用 |
| SoA + SIMD | 0.9 ms | 一次处理 4 个 f32 |
WASM 项目中,性能敏感的数据结构应该优先 SoA——3-10 倍性能提升常见。
3.12.3 字段对齐与 padding
#[repr(C)] 决定字段顺序和 padding:
rust
// 反模式:字段顺序差
#[repr(C)]
struct Bad {
a: u8, // 1 字节
b: u64, // 8 字节,需要 8 字节对齐 → 7 字节 padding
c: u8, // 1 字节
}
// sizeof(Bad) = 24(含 padding)
// 优化:从大到小
#[repr(C)]
struct Good {
b: u64,
a: u8,
c: u8,
// 隐式 6 字节 padding 到 16 字节边界
}
// sizeof(Good) = 16100 万个 Bad 比 Good 多占 8MB——影响缓存行命中率。Rust 默认 #[repr(Rust)] 会自动重排字段优化对齐——只有 #[repr(C)] 才严格按声明顺序。
3.12.4 缓存行与 false sharing
CPU 缓存的最小单位是 64 字节缓存行。两个变量在同一缓存行上时,多线程修改可能触发"伪共享"(false sharing)——即使逻辑上无依赖:
rust
// 反模式:两个原子变量在同一缓存行
struct Counters {
counter_a: AtomicU64, // 偏移 0-8
counter_b: AtomicU64, // 偏移 8-16
// 都在同一缓存行(偏移 0-64)
}
// 修复:用 padding 让每个原子变量独占缓存行
#[repr(align(64))]
struct PaddedCounter(AtomicU64);
struct Counters {
counter_a: PaddedCounter, // 64 字节对齐
counter_b: PaddedCounter,
}WASM 多线程(wasm32-wasi-threads 或浏览器 SharedArrayBuffer)下 false sharing 影响显著——两个 Worker 在不同 atomic 上操作可能慢 10 倍以上。性能敏感的并发数据结构必须考虑 padding。
3.12.5 顺序访问与预取
CPU 的硬件预取器能识别连续访问模式——提前把缓存行加载到 L1。WASM 的访问模式同样受益:
工程含义:算法的访问模式决定缓存命中率。同样 O(n) 的算法,顺序遍历 vs 随机遍历的实际速度可能差 10-50 倍——尤其是数据量大于 L3 缓存(10-50MB)时。
WASM 中没有原生的预取指令(如 x86 的 prefetcht0)——只能依赖硬件预取。这意味着设计算法时应该明确"我的访问模式是什么",让硬件预取能识别。
3.12.6 优化清单
每条都是基础但容易被忽视。性能问题的 80% 在内存访问模式——剩下的 20% 才是算法常数因子优化。
3.13 WASM 内存模型与原生平台的差异
WASM 的内存模型在表面上和原生(Linux/macOS/Windows)相似——都是字节数组+地址寻址。但深入到细节,差异显著影响编程模式和性能预期。理解这些差异有助于把"原生经验"正确迁移到 WASM。
3.13.1 五个根本性差异
每个差异的工程影响:
3.13.2 差异一:地址空间大小
| 维度 | 原生 64 位 | WASM 32 位 | WASM64 |
|---|---|---|---|
| 最大地址空间 | 16 EB(理论) | 4 GB | 16 GB(实际) |
| 单 process | 数 TB(典型) | 4 GB | 视实现 |
| 实际可用 | 受 OS 与硬件限制 | 浏览器通常限 2 GB | 服务端可调高 |
工程影响:处理大数据集(基因组、视频帧、ML 权重)时,4GB 限制可能撞墙。memory64 提案缓解但不普及——生产中需要分块处理大数据。
3.13.3 差异二:内存权限模型
原生平台有 RWX 三种权限:
c
// 原生:mprotect 可设置精细权限
mprotect(ptr, size, PROT_READ); // 仅可读
mprotect(ptr, size, PROT_READ | PROT_WRITE); // 可读写
mprotect(ptr, size, PROT_READ | PROT_EXEC); // 可读+可执行WASM 没有运行时权限——线性内存全是可读可写,代码段不可写:
WASM 内存权限:
- 线性内存:READ + WRITE(不可执行)
- 代码段:EXECUTE(不可读不可写)
- 完全隔离,无 mmap 等价物工程影响:
- JIT 不可在 WASM 内:原生 JIT 编译器写入新代码并标记为可执行——WASM 模块不能动态生成自己的代码(必须经过宿主 / 模块重新实例化)
- 沙箱更严:恶意代码无法把数据当代码执行,攻击面小
3.13.4 差异三:分配策略
原生平台可以用 mmap 在任意虚拟地址分配——稀疏地址空间是普遍的。WASM 必须从 0 开始顺序增长——所有数据在一个连续 buffer 中。
工程影响:
- 碎片管理更难:原生分配器可以"在远处分配大块",WASM 必须在线性内存内压缩
- 无法 unmap 释放:原生 munmap 立即归还内存给 OS,WASM
Vec::clear不归还 - 预分配更重要:避免频繁 grow,预分配大 buffer 是常见模式
3.13.5 差异四:内存视图
原生平台用 mmap 把文件、设备、共享内存映射为内存——零拷贝读取:
c
// 原生:mmap 映射文件,零拷贝读
int fd = open("data.bin", O_RDONLY);
void* ptr = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
// ptr 直接是文件内容WASM 没有 mmap——文件读取必须经过 host 的 IO API + 复制到线性内存:
rust
// WASM:必须复制
let mut buf = Vec::new();
File::open("data.bin")?.read_to_end(&mut buf)?;
// buf 在线性内存中,从文件复制了一份工程影响:处理大文件时,WASM 必须分块读取——不能像原生那样"映射整个文件按需访问"。这影响数据库、文件解析器等场景。
3.13.6 差异五:内存回收语义
原生 free 后内存可能立即归还 OS(取决于分配器策略)——top 命令看到 RSS 下降。WASM 的 Vec::drop 只是标记空闲——线性内存大小不变。
工程影响:
- 长期运行的 WASM 服务必须主动管理:用对象池、定期销毁实例
- 监控指标不同:原生看 RSS,WASM 看
memory.buffer.byteLength - OOM 表现不同:原生 OOM 可能 swap 缓解,WASM OOM 是硬性失败
3.13.7 工程实践对照
每条都是真实的工程模式调整。从原生迁移到 WASM 时,这些差异决定了哪些代码可以直接编译、哪些需要重构。
3.13.8 何时这些差异最显著
如果代码不依赖以上特性——WASM 化是平滑的。依赖任何一项都需要重新设计——这也是 §12.16 介绍的"困难移植"场景的根因。
3.14 表与函数指针的实战工程模式
§3.3 介绍了表的基础概念——但真实业务中如何用表实现 vtable、插件系统、动态分发是更具体的工程问题。这里展开 4 类实战模式。
3.14.1 模式一:trait object 的 vtable
Rust 的 Box<dyn Trait> 在 WASM 中通过表实现:
实际生成的代码:
rust
trait Animal {
fn name(&self) -> &str;
}
struct Dog;
impl Animal for Dog {
fn name(&self) -> &str { "Dog" }
}
fn use_animal(a: &dyn Animal) {
println!("{}", a.name());
}WASM 编译后:
Dog::name函数添加到表(索引 N)&dyn Animal是 (data_ptr, vtable_ptr),vtable 含表索引 Na.name()调用call_indirect (table) (vtable[0])
每次 trait 方法调用都是 call_indirect——表查找 + 类型验证 + 跳转。
3.14.2 模式二:插件系统的函数注册
WASM 模块作为插件时,host 通过表分发调用:
rust
// host 侧(Wasmtime)
let table = instance.get_table(&mut store, "plugin_funcs").unwrap();
// 调用第 i 个插件函数
let func = table.get(&mut store, i).unwrap();
let plugin_func = func.unwrap_func().unwrap();
let typed: TypedFunc<(i32, i32), i32> = plugin_func
.typed(&mut store).unwrap();
let result = typed.call(&mut store, (a, b)).unwrap();WASM 模块导出表:
(table $plugin_funcs 16 funcref)
(elem (i32.const 0) $plug_a $plug_b $plug_c)
(export "plugin_funcs" (table $plugin_funcs))这套机制让 host 能在运行时调用 WASM 内的不同函数——典型的插件分发模式。
3.14.3 模式三:动态分发的优化
call_indirect 比直接调用慢——因为要做表查找 + 类型检查。性能敏感场景的优化:
具体手段:
- 小表:插件超过 100 个时考虑分组,每组独立表
- 单态化:能在编译期决定的调用,用泛型而不是 trait object
- 类型归一化:所有插件函数签名一致,CPU 间接分支预测器命中率高
3.14.4 模式四:函数指针的元编程
WASM 的表让 Rust 代码能存储和传递函数指针:
rust
type Handler = extern "C" fn(i32) -> i32;
#[wasm_bindgen]
pub fn register_handlers(h1: usize, h2: usize) {
// h1, h2 是表索引
HANDLERS[0] = h1;
HANDLERS[1] = h2;
}
#[wasm_bindgen]
pub fn dispatch(idx: usize, arg: i32) -> i32 {
let table_idx = HANDLERS[idx];
let func: Handler = unsafe { std::mem::transmute(table_idx) };
func(arg)
}这种元编程能力让 Rust 代码可以构造"运行时确定的调用图"——例如根据配置动态选择算法实现。
3.14.5 表的安全约束
每个约束都被 WASM 验证器和运行时强制——这让 trait object / 插件系统在 WASM 中比 C 的函数指针更安全。即使有 bug,最坏情况是 trap 而非任意代码执行。
3.14.6 表的生产模式总结
| 场景 | 模式 | 优势 |
|---|---|---|
| trait object | Rust 自动用表 | 类型安全的动态分发 |
| 插件系统 | 显式 export 表 | host 灵活分发 |
| 元编程 | usize 表索引传递 | 运行时构造调用图 |
| 性能优化 | 小表 + 单态化 | 减少 call_indirect 开销 |
理解这 4 种模式后,"WASM 表是间接调用的通道"不再是抽象概念——可以指导真实的工程决策。
3.14.7 表与 Component Model 的关系
组件模型引入了 resource 类型——某种程度上替代了"用表索引传递句柄"的模式:
// MVP:用表索引
fn create() -> i32 { /* 返回表中函数索引 */ }
fn invoke(handle: i32) { /* 通过表索引调用 */ }
// 组件模型:用 resource
resource handler {
constructor();
invoke: func();
}resource 比表索引更类型安全 + 更易理解——但 MVP 的表机制仍是底层实现。组件模型生成的代码内部仍然在用表。
3.14.8 表的使用反模式
每条都是真实坑:
- 巨型表:表元素查找有缓存代价,10000+ 项的表在频繁调用时显著慢
- 跨实例共享:表不是天然跨实例的——共享需要小心同步
- 过度抽象:能用直接调用就别用表,可读性优先
把表当作"必要时的工具"而非"统一抽象"——这是健康的工程态度。
3.15 内存模型与并发安全
§3.5 介绍了 SharedArrayBuffer 基础——但深入到并发安全(指令重排、内存可见性、happens-before),WASM 有自己的形式化模型。理解这套模型是写出正确多线程代码的基础。
3.15.1 WASM 内存模型的设计原则
WASM 选择弱内存模型——比 x86 的 TSO 模型更弱,但更通用(支持 ARM/RISC-V)。开发者必须显式同步而非依赖硬件保证。
3.15.2 原子操作的内存序
WASM 的所有原子指令(i32.atomic.load, i32.atomic.store, i32.atomic.rmw.add 等)都是顺序一致(SeqCst)——这与 C++ atomic 的默认行为一致:
rust
use std::sync::atomic::{AtomicU32, Ordering};
let counter = AtomicU32::new(0);
counter.store(1, Ordering::SeqCst); // WASM 默认这就是 atomic 指令
let v = counter.load(Ordering::SeqCst);WASM 不支持比 SeqCst 更弱的内存序——简化了模型但牺牲了某些极致优化的可能性。
3.15.3 happens-before 关系
关键规则:atomic store 之前的所有普通写操作,对任何 atomic load 之后的读操作可见。这是 happens-before 的核心保证。
3.15.4 Atomics.wait / notify
WASM 提供了"在 atomic 上等待和唤醒"原语:
rust
// 一个线程等待
atomic_wait(addr, expected_value, timeout);
// 另一个线程唤醒
atomic_notify(addr, count);这是实现 mutex / condvar 等同步原语的基础。Rust 的 std::sync::Mutex 在 WASM 上自动使用这套机制。
3.15.5 false sharing 问题
WASM 的多线程下 false sharing 影响显著(§3.12 已讨论)。修复 padding:
rust
#[repr(align(64))]
struct PaddedAtomic(AtomicU64);
struct Counters {
a: PaddedAtomic, // 独占 64 字节缓存行
b: PaddedAtomic, // 独占 64 字节缓存行
}3.15.6 死锁的 WASM 表现
rust
let m1 = Arc::new(Mutex::new(0));
let m2 = Arc::new(Mutex::new(0));
// 线程 1
let _g1 = m1.lock();
let _g2 = m2.lock(); // 等待 m2
// 线程 2
let _g2 = m2.lock();
let _g1 = m1.lock(); // 等待 m1,死锁WASM 的死锁不会让宿主进程崩溃——但会让 Worker 永久挂起,最终触发浏览器的"无响应脚本"机制(30 秒后提示用户)。
调试死锁需要:
- 设置
Atomics.wait超时(不要 Infinity) - 使用 try-lock 模式而非 lock
- 监控线程状态
3.15.7 lock-free 算法
WASM 支持 lock-free 算法——但实现复杂度高:
rust
use std::sync::atomic::{AtomicPtr, Ordering};
struct LockFreeStack<T> {
head: AtomicPtr<Node<T>>,
}
struct Node<T> {
data: T,
next: *mut Node<T>,
}
impl<T> LockFreeStack<T> {
fn push(&self, data: T) {
let new_node = Box::into_raw(Box::new(Node { data, next: ptr::null_mut() }));
loop {
let head = self.head.load(Ordering::Acquire);
unsafe { (*new_node).next = head; }
if self.head.compare_exchange_weak(head, new_node, Ordering::Release, Ordering::Relaxed).is_ok() {
return;
}
}
}
}CAS(compare-and-swap)循环是 lock-free 算法的核心——WASM 的 i32.atomic.rmw.cmpxchg 直接支持。
3.15.8 测试并发代码
rust
#[cfg(test)]
mod tests {
#[test]
fn test_concurrent_increment() {
let counter = Arc::new(AtomicU32::new(0));
let handles: Vec<_> = (0..10).map(|_| {
let c = counter.clone();
std::thread::spawn(move || {
for _ in 0..1000 {
c.fetch_add(1, Ordering::SeqCst);
}
})
}).collect();
for h in handles {
h.join().unwrap();
}
assert_eq!(counter.load(Ordering::SeqCst), 10000);
}
}注意:WASM 的并发测试需要 wasm32-wasi-threads 或浏览器 SAB——不是所有 target 都支持。
3.15.9 形式化验证
WASM 的内存模型有完整的形式化定义——研究者用 Coq / TLA+ 等工具验证:
这种学术严谨性让 WASM 比 C/C++ 等语言的并发模型更可靠——bug 更少、行为更可预测。
3.15.10 工程实践清单
每条都对应过去的并发 bug——遵循这套清单让 WASM 多线程代码既正确又高效。
理解 WASM 的内存模型不是学术好奇——是写出正确多线程代码的工程基础。把这套知识掌握后,WASM 的并发能力可以放心使用。
3.16 跨书关联:与 RAG 检索的对照
本书讨论的 WASM 线性内存模型——一段连续字节数组,通过偏移量直接寻址——和传统数据库的存储模型有结构上的相似性。在《RAG 实战:从检索增强到知识注入》第 12 章"稀疏检索"中,倒排索引的 posting list 本质上也是一种"线性存储 + 偏移寻址"的结构——倒排列表在磁盘/内存中连续排列,通过偏移量随机访问。两者的设计哲学一致:牺牲灵活性(不支持任意指针跳转),换取可预测的访问模式和简单的边界检查。
下一章看虚拟机如何处理 WASM 二进制——从解码、验证、编译到执行的完整流水线。