Rust + WebAssembly 全链路解析

第3章 线性内存与表:WASM 的内存模型

作者 杨艺韬 · 18,191 字 · 发布于 · 更新于

3.1 线性内存:一段字节数组

WASM 的内存模型是所有主流执行环境中最简单的:一段从地址 0 开始的连续字节数组,通过 load/store 指令按偏移量访问。没有虚拟内存,没有页表,没有内存保护位,没有 mmap——就是一块 ArrayBuffer。

这个设计不是疏忽,而是刻意的选择。WASM 的设计目标之一是"可验证的安全性":运行时必须在加载时就能判断一段代码是否安全,而不能依赖操作系统层面的 MMU 保护。线性内存模型让验证器可以静态检查每条 load/store 的对齐约束,运行时只需做一次动态的边界检查(地址 + 访问宽度 <= 内存大小),就能保证不会越界。

graph TB
  subgraph "原生平台内存模型"
    N1["虚拟地址空间 0 ~ 2^48"]
    N2["页表 (4KB 页)"]
    N3["MMU 硬件翻译"]
    N4["权限位: R/W/X"]
    N1 --> N2 --> N3 --> N4
  end
  subgraph "WASM 内存模型"
    W1["线性地址 0 ~ len"]
    W2["单段 ArrayBuffer"]
    W3["软件边界检查"]
    W4["无权限位, 无分段"]
    W1 --> W2 --> W3 --> W4
  end
  style N1 fill:#dc2626,color:#fff
  style W1 fill:#10b981,color:#333

页大小与内存声明

线性内存的声明在模块的 Memory 段中:

(memory (export "memory") 1)    ;; 初始 1 页 = 64KB
(memory (export "memory") 2 16) ;; 初始 2 页,最大 16 页 (1MB)

页大小固定为 64KB(2162^{16} 字节)——这是核心规范硬编码的值,模块无法改。另有一个 custom-page-sizes 提案允许把页大小声明为 1 字节,目前还只是提案:Wasmtime 44 支持但默认关闭(📄 wasmtime-44/docs/stability-wasm-proposals.md:49),webassembly.org 的特性表里主流浏览器都还没有支持(2026-09-24 核对)。

初始页数是必须声明的,最大页数可选。如果指定了最大页数,memory.grow 指令在达到上限后返回 -1(即 0xFFFFFFFF,以无符号解释);如果没有指定最大页数,wasm32 下的上限就是内存类型本身允许的 2162^{16} 页——64KB × 65536 = 4GiB,正好用满 32 位地址空间。实际能拿到多少还受宿主实现限制,浏览器给的往往远小于 4GiB。

一个模块最多声明一块线性内存(MVP 规范的限制)。多内存提案(Multi-Memory)已经走完标准化流程,允许一个模块持有多块内存,由 load/store 的 memarg 里的内存索引指定目标——但 Rust 工具链目前不会生成多内存模块。

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
0x03 共享内存 + 初始 + 最大 initial, max(共享内存必须有 max)

⚠️ 共享内存的 flag 是 0x03 而不是 0x02——线程提案给 flag 的 bit 0 表示"带 max"、bit 1 表示"shared",两位同时置上才是 0x03;只置 bit 1 的 0x02(shared 但无 max)被规范明令禁止(📄 threads 提案 Overview 的 limits 文法:"Note that shared linear memory without an explicit maximum size is not permitted")。memory64 并入 Wasm 3.0 后又用上了 bit 2 表示 i64 地址(0x04–0x07),wasm32 目标不会产生这类 flag。

共享内存(flag=0x03)需要浏览器支持 SharedArrayBuffer,且要求页面配置 COOP/COEP 安全头——这是 Spectre 缓解措施的一部分,3.5 节会详细讨论。

内存指令

所有内存访问通过 load/store 指令完成,每条都带对齐方式和偏移量两个立即数(WAT 里可省略,省略时 offset 为 0、align 取自然对齐):

;; 从地址 (栈顶 i32 + offset=0) 加载一个 i32
i32.load offset=0 align=4

;; 从地址 (栈顶 i32 + offset=0) 加载一个有符号 i8,扩展为 i32
i32.load8_s offset=0 align=1

;; 在地址 (栈顶 i32 + offset=8) 存储一个 i32
i32.store offset=8 align=4

⚠️ WAT 文本里的 align= 写的是字节数(必须是 2 的幂,align=0 会被解析器拒绝),二进制里存的才是它的 log⁡2\log_2:align=4 编码为 2,align=1 编码为 0(Wasmtime 44 实测:align=0 报 "alignment must be a power of two")。对齐是提示而非约束——未对齐的访问仍然合法,只是可能在某些平台上更慢。验证器只检查对齐不超过访问宽度(二进制里即 i32.load 的对齐指数不能超过 2,WAT 里不能超过 align=4)。

实际的访问地址 = 栈顶弹出的 i32 值 + offset 立即数。这和 x86 的 [base + displacement] 寻址模式一致。Rust 编译器会把结构体字段的偏移量编码到 offset 立即数中:

struct Point { x: i32, y: i32 }

fn get_y(p: &Point) -> i32 {
    p.y
}

编译后大致等价于:

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

表里只列了常用的几条;完整集合还有 i64.load8_s/u、i64.load16_s/u,浮点的 f32.load/f64.load,以及对应的各宽度 store。

_s 和 _u 后缀只在加载到更宽的类型时才有意义——窄到宽的扩展必须明确是有符号还是无符号。i32.load 不需要后缀,因为加载宽度等于结果宽度,不存在扩展歧义。

内存增长

memory.grow 指令按页增长线性内存:

;; 请求增长 1 页
i32.const 1
memory.grow
;; 栈顶: 增长前的页数(成功)或 -1(失败)

增长成功时返回旧页数(不是新页数),失败返回 -1(即 0xFFFFFFFF 以无符号解释为 232−12^{32}-1)。失败的原因可能是超出最大页数(声明的 max,或 wasm32 本身的 65536 页上限),也可能是宿主拒绝——内存不足、宿主设的配额(如 Wasmtime 的 ResourceLimiter)都算;规范本身就允许实现让 memory.grow 失败。

一个关键细节:在模块看来,memory.grow 不会重新定位已有数据。新页追加在当前内存的高端,原有内容的线性地址不变——模块内的指针都是从 0 起算的线性地址,必须保持有效。至于宿主是否在底层把这块内存搬到别处(比如没有预留虚拟地址空间的实现),模块感知不到,§3.11.1 会展开。

Rust 的全局分配器(dlmalloc 或自定义分配器)在底层调用 memory.grow。当堆空间不足时,分配器会调用 memory.grow 申请新页,然后在新的页上切分出需要的块。由于 memory.grow 只能申请整页,分配器需要自己做页内切分和碎片管理——第 9 章会详细分析。

3.2 Rust 的内存布局在 WASM 中的映射

Rust 的内存模型和 C 一样:全局静态区、堆、栈。编译到 WASM 后,这三个区域全部映射到同一段线性内存中——没有段寄存器,没有独立的地址空间。

⚠️ 这里有个很多教程都写错的细节:Rust 编译到 wasm32 时,栈在最低地址,不在最高地址。wasm-ld 自己的默认布局确实是"空白页 → 静态数据 → 栈",但 rustc 给 wasm 目标默认加了 --stack-first,把栈挪到了静态数据前面。理由写在 rustc 的目标定义里(📄 rust 仓库 compiler/rustc_target/src/spec/base/wasm.rs:15-30 的注释):默认布局下栈溢出会覆盖静态数据,制造极难排查的诡异 bug;把栈放在最前面后,溢出会一路减到 0 以下、直接 trap。代价是静态数据的地址变大,LEB128 编码多占几个字节。同一个文件还把默认栈大小从 LLD 的 64KB 提到了 -z stack-size=1048576(1MB,同文件 14 行)。

graph TB
  subgraph "线性内存布局 (rustc + --stack-first, wasm32)"
    A["0x000000 ┌────────────────────┐"]
    H["         │  栈(默认 1MB)      │  ← 向低地址增长,溢出即 trap"]
    I["         │  (栈顶 = __stack_pointer, 一个 mut i32 全局)"]
    C["         ├────────────────────┤"]
    B["         │  静态数据 (Data 段)  │  ← static 变量, 字符串字面量"]
    C2["        ├────────────────────┤"]
    D["         │  堆 (__heap_base 起) │  ← dlmalloc 向高地址增长"]
    F["         │  空闲区域            │"]
    J["  内存末端 └────────────────────┘  ← memory.grow 在这里追加新页"]
  end
  A --> H --> I --> C --> B --> C2 --> D --> F --> J
  style A fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333
  style H fill:#10b981,color:#333

栈

WASM 规范没有显式的栈段——函数的局部变量和调用栈帧在规范中是抽象的。但实际实现中,编译器会在线性内存里划一块区域当作影子栈,用一个可变全局变量(在 Rust 产物里通常就是 global[0])作为栈指针;如上所述,rustc 通过 --stack-first 把这块区域放在了线性内存的最低端。

Rust 编译到 wasm32 目标时会生成一个可变的 i32 全局变量 __stack_pointer 指向栈顶(链接器另外导出 __heap_base 和 __data_end 两个符号,📄 rust 仓库 compiler/rustc_codegen_ssa/src/back/linker.rs:1471;注意这一行有 os 判断,只在 unknown/none 这类裸 wasm 目标上加,wasi 目标不走这条)。函数入口处递减栈指针分配帧空间,出口处恢复——和原生平台的栈帧管理完全一致。

一个典型的函数入口/出口序列(按 Rust 1.96.0 --release 产物整理):

;; 函数入口
global.get 0          ;; 读取 __stack_pointer
i32.const 16          ;; 帧大小 = 16 字节
i32.sub               ;; 新栈顶 = __stack_pointer - 16
local.tee 1           ;; 帧基址存进一个 local,函数体用它寻址
global.set 0          ;; 更新 __stack_pointer
;; ... 函数体 ...
;; 函数出口
local.get 1
i32.const 16
i32.add               ;; 帧基址 + 16,恢复 __stack_pointer
global.set 0

这里要分清两个栈。一个是 WASM 规范里的抽象值栈和 local 槽位——参数、返回值和大多数局部变量都活在这里,它们不在线性内存中,JIT 之后通常直接落到物理寄存器上。另一个是编译器在线性内存里自己搭的"影子栈"(shadow stack),由 __stack_pointer 管理。只有那些 WASM 的 local 装不下的东西才会进影子栈:被取了地址的变量(&x)、结构体和数组这类聚合值、以及寄存器不够时溢出的临时值。所以上面那段"入口减 16、出口加 16"的序列,只在函数确实需要影子栈空间时才会生成——纯计算的小函数根本不碰它。

堆

WASM 没有内建的堆分配器——memory.grow 只能申请整页,不能分配任意大小的块。Rust 必须自带一个分配器:

分配器 体积开销 分配速度 特点
dlmalloc 数 KB 量级,几者中最大 快 Rust 标准库默认选择(📄 rust 仓库 src/doc/rustc/src/platform-support/wasm32-unknown-unknown.md:58:"This target uses the dlmalloc crate as the default global allocator";实现在 library/std/src/sys/alloc/wasm.rs),功能完整,支持 malloc/free 语义
wee_alloc 较小 较慢 社区开发,专注小体积;仓库已归档、停止维护,不建议新项目采用
lol_alloc 很小 视具体分配器而定 一组极简分配器:从只分配不回收的 bump/泄漏式,到带空闲链表的 FreeListAllocator
talc 小 快 近期新选择,配合 spin 锁可支持多线程

这张表说的是 wasm32-unknown-unknown。wasm32-wasip1/wasip2 上 std 不走 sys/alloc/wasm.rs,而是按 unix 路径调用 wasi-libc 的 malloc(📄 rust 仓库 library/std/src/sys/alloc/mod.rs:71-78)。

分配器选择直接影响 .wasm 体积和运行时性能。对于体积敏感的场景,talc 或 lol_alloc 是合理选择——wee_alloc 仓库已归档、停止维护且有未修复的内存不归还问题,新项目不要再选它(§9.4.2、§11.2.2);对于分配密集的场景,功能完整的 dlmalloc 更稳妥。第 9 章会详细分析不同分配器的 trade-off。

全局静态区

Rust 的 static 变量和字符串字面量被放在栈之后(--stack-first 下默认从 1MiB 处开始),带非零初值的部分通过数据段(Data Section)在模块实例化时写入:

static COUNTER: AtomicU32 = AtomicU32::new(0);
static GREETING: &str = "hello world";

const MAX_SIZE: usize = 1024;

GREETING 指向的字符串字节进数据段,在实例化时写入线性内存的固定偏移;COUNTER 初值为 0,落在 .bss,只占地址、不进数据段——新内存本来就是全零。MAX_SIZE 作为编译期常量直接内联到指令中——不占线性内存。

用 Rust 1.96.0 实编一个小 cdylib(--release --target wasm32-unknown-unknown)可以看到这套布局的实际数字:__stack_pointer 初值 1048576(栈占 [0, 1MiB)),唯一的数据段写在 i32.const 1048576,__data_end = 1049825、__heap_base = 1049840,初始内存 17 页(16 页栈 + 1 页数据)。同样的代码编成 wasm32-wasip1 可执行文件,__stack_pointer 初值同样是 1048576、初始内存同样 17 页,只是不导出 __heap_base/__data_end。

数据段的二进制编码:

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"

各个 static 的地址由链接器分配,wasm-ld 按输出段(.rodata、.data 等)合并成少数几个数据段——上面的实测产物里只有一个 .rodata 段。wasm-bindgen 的元数据则不走数据段:宏把它写进名为 __wasm_bindgen_unstable 的自定义段(📄 wasm-bindgen crates/macro-support/src/codegen.rs:181),由 CLI 读出后移除(📄 crates/cli-support/src/wit/mod.rs:1796)。

3.3 表:间接调用的通道

如果说线性内存是 WASM 的数据区,表(Table)就是 WASM 的函数引用区。在 GC 提案引入结构体/数组之前,表是 WASM 里唯一能成批存放引用类型(funcref、externref)的数据结构。

为什么需要表

WASM 的 call 指令只能做直接调用——目标是模块内的一个函数索引,在二进制中已经确定。但 Rust 的 dyn Trait、函数指针、回调机制都要求间接调用——运行时才决定调用哪个函数。

WASM 的安全模型不允许把函数地址直接当作整数传递——函数不是线性内存中的对象,它们是虚拟机的内部结构。如果允许模块直接操作函数指针的数值,就可以构造任意跳转——这和 ROP 攻击是同一类问题。表的设计把"函数引用"和"整数地址"隔离——函数引用只能存在表中,只能通过表索引间接调用,调用时强制验证签名。

;; 声明一个表,初始 2 个元素,类型为 funcref
(table 2 funcref)

;; 元素段:把函数 0 和函数 1 放入表
(elem (i32.const 0) func 0 1)

call_indirect 指令通过表索引进行间接调用:

;; 调用表中索引为 0 的函数,签名必须是 (i32) -> i32
i32.const 42    ;; 实参
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,现已并入 WebAssembly 2.0 核心规范)新增的类型,存储宿主侧的对象引用——比如 JavaScript 的 Object、DOM 元素等。

graph LR
  subgraph "WASM 模块内部"
    A["funcref<br/>指向模块内函数"]
  end
  subgraph "宿主侧"
    B["externref<br/>指向 JS Object / DOM 节点"]
  end
  A -->|"call_indirect"| C["执行 WASM 函数"]
  B -->|"传递给导入函数"| D["JS 侧使用对象"]
  style A fill:#6366f1,color:#fff
  style B fill:#f59e0b,color:#333

externref 的出现改变了 wasm-bindgen 的设计——早期版本用 i32 索引指向 JS 侧的对象栈(生成的 JS 里有一整套 heap 数组和 stack_pointer);有了 externref,JS 侧那套 heap 数组被 WASM 模块里的一张 externref 表取代。Rust 里的 JsValue 仍然是 i32 索引,只在导入/导出的边界上用 externref,进来后立即转成这张表的索引(📄 wasm-bindgen crates/cli-support/src/transforms/externref/mod.rs 开头的模块注释)。今天这是默认路径:reference-types 在 wasm32-unknown-unknown 上默认开启——rustc 自己的目标文档把默认打开的提案逐条列了出来,multivalue、mutable-globals、reference-types、sign-ext 都在其中,bulk-memory 和 nontrapping-fptoint 则标注为 Rust 1.87.0+ 才加入(📄 rust 仓库 src/doc/rustc/src/platform-support/wasm32-unknown-unknown.md:125-133);reference-types 这一条是随 LLVM 19 进来的,对应 Rust 1.82(rust-lang/rust#127513)。wasm-bindgen 直接读模块的 target feature 自动启用 externref 变换,旧的 --reference-types 开关上挂着 #[deprecated],弃用理由写的就是"已改由 -Ctarget-feature=+reference-types 自动探测"(📄 crates/cli-support/src/lib.rs:127)。

表与 Rust 的 dyn Trait

Rust 的 dyn Trait 编译到 WASM 时,虚表(vtable)和 call_indirect 的关系:

sequenceDiagram
  participant R as Rust 代码
  participant V as vtable (线性内存)
  participant T as WASM 表 (funcref)
  participant W as WASM 虚拟机
  R->>V: 读取 dyn 对象的 vtable 指针
  V->>T: 查找方法对应的表索引
  T->>W: call_indirect 表索引
  W->>W: 检查 table[i] != null
  W->>W: 验证签名匹配 type 立即数
  W->>W: 执行目标函数

注意两层间接:Rust 的 vtable 在线性内存中存储方法指针(i32),方法指针的值就是 WASM 表的索引(不是函数在模块里的函数索引);call_indirect 通过表索引跳转到实际函数。这和原生平台上的 vtable 实现结构相同——只是间接调用从 CPU 的 call [rax+offset] 变成了 WASM 的 call_indirect。

call_indirect 的验证步骤:

  1. 从栈顶取出表索引 i
  2. 检查 i < table.size,否则 trap(Out of bounds table access)
  3. 从表中取出 table[i]
  4. 检查 table[i] 不是 null(funcref 可以为 null),否则 trap
  5. 检查 table[i] 的函数签名与 call_indirect 的 type 立即数一致,否则 trap(Indirect call type mismatch)
  6. 执行函数

越界、null、签名这三层检查确保了间接调用的类型安全——这是 WASM 安全模型的关键保障。其中 null 检查和签名检查是 WASM 间接调用比原生平台的间接调用更安全的原因:原生平台的间接调用(call [rax])不做任何签名验证,一个错误的函数指针可能导致执行任意代码;WASM 的 call_indirect 保证只能调用签名匹配的函数。

表的增长

和内存一样,表可以增长:

;; 操作数: 新元素的初值(一个引用)和增长数量 n,n 在栈顶
ref.null func
i32.const 1
table.grow 0   ;; 弹出两者,压入旧大小(成功)或 -1(失败)

表增长时,新元素一律填成指令给出的那个初值(上例是 null)——这和 memory.grow 新页全零不同,初值由调用方指定。Rust 的动态分发通常不需要运行时增长表——vtable 的大小在编译时已确定,所有元素段(Element Section)在模块定义时就填充好了。

但动态链接场景(如 Emscripten 的 dylink 约定,多个模块共享一张表)需要运行时增长表——新加载的模块要把自己的函数注册到共享的表中,这个注册过程涉及 table.grow + table.set。组件模型的组合走的是导入/导出,不靠共享表,见第 14 章。

Element 段

Element 段(元素段)用于初始化表的内容:

;; 声明方式 1:活跃元素段,在实例化时自动写入
(elem (i32.const 0) func 0 1 2)   ;; 把函数 0, 1, 2 写入表索引 0, 1, 2

;; 声明方式 2:被动元素段,不自动写入,留给 table.init
(elem func 3 4 5)

;; 声明方式 3:声明式元素段,只声明函数 6, 7 可被 ref.func 引用
(elem declare func 6 7)

;; 声明方式 4:活跃元素段 + 元素表达式,写进一张 externref 表
(elem (table $ext) (i32.const 0) externref (ref.null extern))  ;; $ext 的索引 0 放入 null

Element 段的二进制编码有 8 种变体(flags 取 0~7),支持活跃/被动/声明式三种模式,以及"函数索引列表"和"元素表达式列表"两种元素写法(📄 核心规范 · Element Section:bit 0 区分被动/声明式与活跃,bit 1 表示活跃段带显式表索引、否则区分被动与声明式,bit 2 表示用元素类型 + 元素表达式而不是 elemkind + 函数索引)。被动元素段不会在实例化时自动初始化,而是由 table.init 指令在运行时手动写入——这对延迟初始化和动态链接场景有用。

3.4 边界检查与 Trap

WASM 的每一条 load/store 指令在执行时都要做边界检查。检查逻辑:

effective_address = operand + offset
if effective_address + access_width > memory_size:
    trap!

边界检查是 WASM 沙箱安全的核心机制之一。规范只规定越界必须 trap,不规定怎么实现:最直接的做法是在每条内存指令前嵌入软件比较;而 Wasmtime 在 64 位宿主上默认给每块 32 位内存预留 4GiB 虚拟地址外加保护页,越界访问落进未映射区域、由信号处理转成 trap,显式比较大多可以省掉(§3.11.1)。下面讨论的是需要显式比较时的情形。

边界检查的性能影响

在 JIT 编译后的代码中,边界检查通常编译为一条比较指令 + 条件跳转:

; i32.load offset=0,rcx = 有效地址,rbx = 线性内存基址
cmp rcx, memory_size     ; 先比较
jae trap_handler         ; 越界则跳到 trap 处理
mov eax, [rbx + rcx]     ; 通过后才真正加载

⚠️ 顺序很重要:检查必须在访问之前,否则沙箱就漏了。在热循环里这对指令会被反复执行,但它们的代价通常远小于访存本身,而且编译器还能做边界检查消除(bounds check elimination):

  1. 如果循环变量 i 的范围可以静态推导(比如 i < len),且 len <= memory_size - access_width,则循环内的 load/store 原则上不需要检查——这是通用的范围分析技术,各引擎做到什么程度本章未逐一核对。
  2. 如果访问地址是编译期常量、加上 offset 和访问宽度后仍不超过模块声明的最小内存大小,就可以消除检查——Wasmtime 的 Cranelift 后端就有这条特判(📄 wasmtime-44/crates/cranelift/src/bounds_checks.rs:658 的 statically_in_bounds)。

Rust 的 unsafe { slice.get_unchecked(i) } 在 WASM 中跳过 Rust 侧的边界检查,但 JIT 仍然可能为底层的 i32.load 插入边界检查——除非 JIT 的分析能证明安全。也就是说:unsafe 消除的是 Rust 编译器生成的检查,不一定消除 JIT 生成的检查。

Trap 类型

常见的 trap 类型及其触发条件(前六种情形由核心规范规定必须 trap,英文名沿用常见引擎的报错措辞;栈耗尽不在规范的 trap 列表里,规范只说"实现的运行时限制被突破时可以终止计算并报一个实现自定的错误",📄 核心规范 · 附录 Implementation Limitations):

Trap 原因 触发条件 Rust 等价
Unreachable 执行 unreachable 指令 unreachable!() / core::hint::unreachable_unchecked() 误用
Integer divide by zero i32.div_s / i32.div_u 除以零 a / 0——但安全 Rust 会先检查并 panic,实际落到的是 unreachable
Integer overflow i32.div_s 的结果溢出 i32 范围 i32::MIN / -1——同上,先 panic
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 异常(下例假设 divByZero 是直接执行 i32.div_s 的手写函数):

try {
  instance.exports.divByZero(1, 0);
} catch (e) {
  console.log(String(e)); // RuntimeError: divide by zero(Node 24 / V8 实测)
}

同样的调用换成 Rust 写的 a / b(Rust 1.96.0 编译),打印的是 RuntimeError: unreachable:rustc 在 i32.div_s 前插了除零和溢出检查,panic 后走到 unreachable。

关键点:trap 是不可恢复的——MVP 阶段的 WASM 没有 try-catch,trap 意味着整个调用链被中断,且 WASM 侧无法拦截。异常处理提案(Wasm EH)补上了 throw、try_table 等指令(早期版本是 try/catch),但它捕获的是异常,不是 trap——trap 依然穿透一切直达宿主。Rust 在 wasm32-unknown-unknown 上默认就是 panic=abort,panic 最终落到 unreachable 指令,也就是一个 trap。

3.5 共享内存与线程

WASM 的线程提案(threads)允许线性内存被多个 Web Worker 共享,在 JS 侧对应的是 SharedArrayBuffer:

(memory 1 16 shared)  ;; 共享内存,初始 1 页,最大 16 页;shared 必须同时给出 max

共享内存配合 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.atomic.wait32 / memory.atomic.wait64 / memory.atomic.notify 类似 Linux futex

Rust 的 std::sync::atomic 编译到 WASM 时就使用这些原子指令:

use std::sync::atomic::{AtomicI32, Ordering};

static SHARED_COUNTER: AtomicI32 = AtomicI32::new(0);

fn increment() -> i32 {
    SHARED_COUNTER.fetch_add(1, Ordering::SeqCst)
}

开启 atomics target feature 编译时,fetch_add 变成 i32.atomic.rmw.add。默认的 wasm32-unknown-unknown 不开 atomics,同一段代码被当成单线程处理,编成普通的 i32.load + i32.add + i32.store(Rust 1.96.0 实测)。atomics 在 1.96.0 仍是不稳定的 target feature,stable 编译器会给出 "unstable feature specified" 警告;真要跑多线程还得用 -Z build-std 重编 std 并让链接器生成共享内存。

graph TB
  subgraph "Worker 1"
    A1["WASM 实例 1<br/>(独立全局,含 __stack_pointer)"]
    A1 -->|"指向共享内存中的"| M1["本线程的栈区"]
    A1 -.->|"i32.atomic.rmw.add"| S["共享内存<br/>(SharedArrayBuffer)<br/>静态数据 + 堆 + 各线程栈"]
  end
  subgraph "Worker 2"
    A2["WASM 实例 2<br/>(独立全局,含 __stack_pointer)"]
    A2 -->|"指向共享内存中的"| M2["本线程的栈区"]
    A2 -.->|"i32.atomic.rmw.add"| S
  end
  S --> T["memory.atomic.wait32 / memory.atomic.notify"]
  style S fill:#f59e0b,color:#333
  style T fill:#f59e0b,color:#333

WASM 的线程模型有一个关键限制:WASM 模块本身不是线程——它只是代码和数据。浏览器中,WASM 模块跑在 JS 主线程或 Web Worker 中,同一模块可以被多个 Worker 实例化。各实例的全局变量(包括 __stack_pointer)是私有的,线性内存只有共享的那一块:堆、静态数据是共用的,每个线程的栈也只是这块共享内存里划给它的一段区域。WASM 规范不定义调度——调度由宿主决定。

实际使用中需要注意:

  1. COOP/COEP 安全头:浏览器要求页面设置 Cross-Origin-Opener-Policy: same-origin 和 Cross-Origin-Embedder-Policy: require-corp(或 credentialless)才允许使用 SharedArrayBuffer——这是 Spectre 缓解措施。
  2. 分配器的锁是自旋的:常见说法"wasm 上的 dlmalloc 不是线程安全的、必须自己换一个带锁的 global_allocator"是错的——std 在开了 atomics 时会给每次 alloc/dealloc/realloc 套一把全局锁(📄 rust 仓库 library/std/src/sys/alloc/wasm.rs:64-76,#[cfg(target_feature = "atomics")] mod lock)。真正的问题是这把锁只能自旋:主线程不许阻塞,用不了 i32.atomic.wait,源码注释直言"spinning here is not a great solution"(同文件 124 行)。所以高竞争下代价在 CPU 空转,而不是数据损坏;换分配器要看的是它自己有没有做同步(bump 型的极简分配器通常没有)。
  3. memory.atomic.wait* 不能在主线程用:浏览器不允许阻塞主线程,主线程上执行等待指令会直接失败(与 JS 侧 Atomics.wait 在主线程抛 TypeError 是同一条规则)。std 的 wasm 分配器注释把这条约束写得很直白——"The main thread in a web browser cannot ever block, no exceptions. This means that the main thread can't actually execute the i32.atomic.wait instruction."(📄 rust 仓库 library/std/src/sys/alloc/wasm.rs:93-95)。只有 Worker 里才能真正阻塞等待。

3.6 内存视图:JS 侧如何访问 WASM 内存

JavaScript 通过 WebAssembly.Memory 对象的 buffer 属性访问线性内存。buffer 是一个 ArrayBuffer(或 SharedArrayBuffer),可以用 TypedArray 视图操作:

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 失效。

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 的做法不是每次都重建视图,而是缓存 + 失效检测(📄 crates/cli-support/src/js/mod.rs 的 memview(),生成的代码形如;下面这段是展开后的产物示意,不是源码原文——源码里它是一段 format! 模板):

let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
    if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
        cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
    }
    return cachedUint8ArrayMemory0;
}

模板本体在 crates/cli-support/src/js/mod.rs 的 2864-2876 行,三条失效判据的分支就在它上面几行(2849 起)。byteLength === 0 就是"这个 buffer 已经 detached"的廉价判据——增长过后旧 buffer 的长度归零。共享内存(SharedArrayBuffer)走另一条分支:增长不会 detach 旧引用,所以改为比较 cache.buffer !== wasm.memory.buffer;DataView 又是一条分支,因为访问已 detach 的 DataView 的 byteLength 会抛异常,要先看 buffer.detached。

更安全的做法是用 getter 封装:

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 块的错误,和原生平台一样会导致内存损坏。

graph TD
  A["Rust 源码"] --> B{"有 unsafe?"}
  B -->|"纯安全 Rust"| C["编译期保证内存安全 ✓"]
  B -->|"含 unsafe"| D["运行时可能内存损坏"]
  C --> E["WASM: 安全"]
  D --> F["WASM: 可能 trap 或数据损坏"]
  E --> G["沙箱保证: 不影响宿主"]
  F --> G
  G --> H["最坏情况: 仅 WASM 模块自身逻辑出错"]

沙箱 + Rust 所有权 = 双层防御:

防御层 机制 保护范围
Rust 编译期 所有权 + 借用检查 + 生命周期 消除绝大多数内存 bug
WASM 运行时 线性内存边界检查 + 沙箱隔离 阻止残余 bug 扩散到宿主

即使 Rust 代码中有一个 unsafe 导致的缓冲区溢出,最坏情况是 WASM 模块自身的逻辑出错——不会影响浏览器标签页中的其他内容,不会影响 JS 堆,不会影响 DOM。这和原生平台上的缓冲区溢出形成鲜明对比——原生平台上,越界写可能覆盖相邻的堆元数据,导致任意代码执行。

但也存在沙箱不能防止的问题:

  1. 宿主侧可见:Rust 产物默认就导出 memory(wasm-bindgen 的胶水也靠它),拿到导出的 JS 可以读写整个线性内存——包括栈区和全局区。沙箱隔离的是模块对外的访问,不是宿主对模块内存的访问,别把密钥之类的数据当成对 JS 不可见。
  2. 侧信道攻击:WASM 的共享内存可能被用于 Spectre 类侧信道攻击——这就是浏览器要求 COOP/COEP 头的原因。
  3. 资源耗尽:WASM 模块可以调用 memory.grow 消耗大量内存——沙箱不限制资源配额,需要宿主自行实现。

3.8 内存指令的二进制编码

WASM 的每条内存指令在二进制中的编码都遵循一个固定模式:操作码 + 可选的 offset/align 立即数。理解这些编码对分析 .wasm 体积至关重要——第 9 章的体积优化大量依赖对内存指令编码的理解。

指令 操作码 立即数(memarg,顺序固定为 align 在前) 编码示例
i32.load 0x28 align, offset 28 02 00(对齐指数 2 即 4 字节, offset=0)
i32.load8_s 0x2C align, offset 2C 00 04(对齐指数 0 即 1 字节, offset=4)
i32.store 0x36 align, offset 36 02 08(对齐指数 2, offset=8)
i64.load 0x29 align, offset 29 03 00(对齐指数 3 即 8 字节, offset=0)
f64.load 0x2B align, offset 2B 03 00(对齐指数 3, offset=0)

⚠️ 二进制里 memarg 的顺序是 align 在前、offset 在后,而 WAT 文本里写成 offset=... align=...,且 WAT 的 align= 是字节数、二进制存的是它的 log⁡2\log_2——读字节流时别把两者搞反。align 和 offset 都用 LEB128 编码。对于 Rust 编译的 .wasm,结构体字段的偏移量通常在 0-127 范围内(1 字节 LEB128),对齐值在 0-3 范围内(1 字节 LEB128)。所以一条 i32.load 指令通常占 3 字节:1 字节操作码 + 1 字节 align + 1 字节 offset。

当结构体很大(偏移量超过 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.init 指令手动写入
2 活跃段,指定 memory 索引 多内存提案下的变体

⚠️ 别把 1 和 2 记反:规范里 bit 0 表示"被动"、bit 1 表示"活跃段带显式内存索引"(📄 核心规范 · Data Section)。默认产物只使用 flags=0 的活跃段。被动段本身不会让 .wasm 变小(数据字节照样在段里),它解决的是"什么时候、由谁写入":典型场景是共享内存——多个线程各自实例化同一模块,活跃段会让每次实例化都把初值重写一遍,覆盖别的线程已经改过的数据。所以 wasm-ld 在 --shared-memory 下把数据段全部改成被动段,并生成一个 __wasm_init_memory 作为 start 函数:用一个原子标志保证只有第一个实例执行 memory.init(以及给 .bss 做 memory.fill),其他实例用 memory.atomic.wait32 等它完成,最后 data.drop 释放段数据(Rust 1.96.0 以 +atomics,+bulk-memory 加 --shared-memory --import-memory 编一个 no_std cdylib 实测)。此外,被动段也可以用来按需、延迟初始化某块内存。

Data 段的典型编码(为了看清结构,偏移取 0):

0B                    ; Section ID = 11 (Data)
  11                  ;   Section 大小 = 17 字节
  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"

字符串 "hello world" 被编码为一个 11 字节的数据段,写入线性内存偏移 0 处。真实的 Rust 产物因为 --stack-first,偏移是 i32.const 1048576,编码为 41 80 80 C0 00 0B(§3.2 那个实测产物里就是这 6 个字节)。多个 static 的数据由链接器按输出段合并成少数几个数据段,每个变量的数据按偏移排列。

Element 段的编码

Element 段也有多种模式,比 Data 段更复杂——一共 8 种 flags(0~7),低四种用"elemkind + 函数索引列表",高四种用"引用类型 + 元素表达式列表":

Flags 模式 元素写法 说明
0 活跃段,table 0,offset 常量 函数索引(elemkind 隐式为 funcref) 最常见模式
1 被动段 显式 elemkind + 函数索引 不自动写入
2 活跃段,指定 table 索引,offset 常量 显式 elemkind + 函数索引 多表提案
3 声明式 显式 elemkind + 函数索引 只声明函数引用关系
4 活跃段,table 0,offset 常量 元素表达式 可放 ref.null 等
5 被动段 引用类型 + 元素表达式 —
6 活跃段,指定 table 索引 引用类型 + 元素表达式 —
7 声明式 引用类型 + 元素表达式 —

⚠️ 同样别把 1 和 2 记反——被动段是 1,"活跃段 + 显式表索引"是 2(📄 核心规范 · Element Section)。

Rust 产物里函数指针和 vtable 方法要用的表项,由 wasm-ld 生成一个 flags=0 的活跃段在实例化时填好——§3.2 那个实测产物里就是一个从表索引 1 开始的 flags=0 段,索引 0 留空(值为 0 的函数指针对应的是空表项,调用即 trap)。声明式元素段(flags=3)则是给 ref.func 用的:验证规则要求 ref.func 引用的函数必须事先在元素段等处声明过,声明式段只做这个声明、不写入任何表。

3.10 memory64 与 multi-memory 提案

WASM MVP 规范限制了内存寻址——线性内存只有一段、地址用 32 位、最大 4GB。这些限制对应早期"浏览器内小工具"的定位,但对服务器端、ML 推理、大规模数据处理来说成为瓶颈。两个提案突破这些限制:memory64(64 位寻址)和 multi-memory(多段内存)。

3.10.1 memory64 提案:突破 4GB 限制

graph LR
  subgraph "MVP wasm32"
    A["i32 地址"] --> B["最大 4GB 线性内存"]
    B --> C["最大页数 65536"]
  end
  subgraph "memory64"
    D["i64 地址"] --> E["理论最大 16 EB<br/>实际受运行时限制"]
    E --> F["页大小仍 64KB<br/>页数可达 2^48"]
  end
  style B fill:#dc2626,color:#fff
  style E fill:#10b981,color:#333

memory64 的实际限制:

维度 wasm32 wasm64
地址类型 i32 i64
最大内存 4 GiB(2162^{16} 页) 规范上限远高于此,实际由运行时配额决定
内存指令 地址操作数是 i32 地址操作数是 i64
Wasmtime 44 默认开启 也默认开启——Config::features() 这个真正算生效特性集的函数里有一行 `features

启用 memory64 的代价:地址立即数变宽让内存指令编码变长,边界检查也不能再依赖 32 位地址空间"一定装得下 4GiB 保护区"的技巧,只能退回显式比较。所以除非业务确实需要超过 4GiB(大型 ML 模型、大块视频缓冲),否则优先 wasm32。

3.10.2 Rust 启用 memory64

Rust 有一个 wasm64-unknown-unknown target 支持 memory64,但它是 Tier 3:官方不发预编译的 std,rustup target add 装不上,只能用 nightly 的 build-std 自己编(📄 rust 仓库 src/doc/rustc/src/platform-support/wasm64-unknown-unknown.md):

# 需要 nightly 和 rust-src 组件
rustup component add rust-src --toolchain nightly

# 编译,同时从源码构建 std
cargo +nightly build -Z build-std=std,panic_abort --target wasm64-unknown-unknown --release

代码中的 usize 和指针都变成 8 字节。wasm-bindgen 0.2.118 的源码里没有任何 wasm64/memory64 相关处理,浏览器侧的 Rust 生态还用不上它,生产使用建议谨慎。

3.10.3 multi-memory 提案:多段独立内存

graph TB
  subgraph "MVP:单内存"
    A["WASM 模块"] --> B["唯一线性内存<br/>所有数据共享"]
  end
  subgraph "multi-memory"
    C["WASM 模块"] --> D["内存 0:代码堆"]
    C --> E["内存 1:用户数据"]
    C --> F["内存 2:临时缓冲"]
  end
  style B fill:#f59e0b,color:#333
  style D fill:#10b981,color:#333
  style E fill:#10b981,color:#333
  style F fill:#10b981,color:#333

multi-memory 允许一个模块声明多段独立的线性内存——指令带额外参数指定操作哪段:

(module
  (memory $stack 1)        ;; 内存 0
  (memory $heap 16)        ;; 内存 1
  (func (export "test")
    i32.const 0
    i32.const 42
    i32.store $heap))  ;; 写入 heap 内存(内存索引紧跟在指令名后)

应用场景:

  • 隔离敏感数据:把密钥放独立内存,普通业务代码不能寻址
  • 共享与私有分离:内存 0 通过 SharedArrayBuffer 共享、内存 1 私有
  • 大文件处理:每个文件一段内存,处理完独立 free(避免线性内存的碎片)

支持状态:Chrome 120+、Firefox 125+ 已支持,Safari 尚未支持(webassembly.org 特性表,2026-09-24 核对);Wasmtime 44 默认开启——Config::wasm_multi_memory 文档写着它默认为 true(📄 crates/wasmtime/src/config.rs:1121),Config::features() 里也有 features |= WasmFeatures::MULTI_MEMORY;。但 Rust 这一侧还跟不上:wasm-bindgen 0.2.118 的源码里搜不到任何 multi-memory 处理,rustc 也不会生成多内存模块。也就是说,multi-memory 目前是宿主侧就绪、Rust 侧未就绪——想用得手写 WAT 或用 wasm-tools 后处理。

3.10.4 何时使用这些提案

flowchart TD
  A["内存需求"] --> B{"> 4GB?"}
  B -->|是| C["memory64<br/>+ 浏览器支持检查"]
  B -->|否| D{"需要内存隔离?"}
  D -->|是| E["multi-memory<br/>+ 多内存声明"]
  D -->|否| F["wasm32 单内存<br/>足够"]
  style F fill:#10b981,color:#333
  style C fill:#f59e0b,color:#333
  style E fill:#6366f1,color:#fff

绝大多数 WASM 项目不需要这两个提案——4GB 单内存够用且兼容性最好。memory64 适合服务器端的大数据处理(Wasmtime 等运行时支持充分)。multi-memory 适合需要内存隔离的安全敏感场景,但 Safari 尚未支持、Rust 工具链也不生成。

3.11 memory.grow 与零初始化的语义陷阱

WASM 的内存增长(memory.grow 指令)是 MVP 规范中最容易被误用的部分——它的成本、语义和陷阱常被忽视。理解这些细节有助于避免生产事故。

3.11.1 memory.grow 的真实开销

memory.grow(N) 申请 N 个 64KB 页。它的成本取决于宿主怎么实现线性内存——这里最容易踩的坑是想当然地以为"每次 grow 都要复制整块内存"。

Wasmtime 的做法是预留虚拟地址空间:在 64 位宿主上默认给每块线性内存预留 4GiB 的虚拟地址区间,外加 32MiB 的保护区(📄 crates/environ/src/tunables.rs:263-264 的 default_u64():memory_reservation: 1 << 32、memory_guard_size: 32 << 20;32 位宿主则降到 10MiB,同文件 242 行)。Config::memory_reservation 的文档说明了这个数字的来由:32 位线性内存只有在预留量至少 4GiB 时才能把边界检查整个省掉(📄 crates/wasmtime/src/config.rs:1699-1700)。预留区里只有 initial 那一段是可访问的,其余是保留但未映射的 capacity,memory.grow 做的事情就是把 capacity 里的一部分变成可访问——不搬数据。这同时也是 §3.4 说的"显式比较大多可以省掉"的实现基础:4GiB 预留加保护页之后,32 位地址无论如何都跳不出预留区,编译器可以直接省掉显式比较。

graph LR
  G1["guard<br/>保护页"] --- I["initial<br/>可访问"] --- C["capacity<br/>已预留 · 未映射"] --- G2["guard<br/>保护页"]
  C -.->|"memory.grow 把这一段变为可访问"| I
  style I fill:#10b981,color:#333
  style C fill:#f59e0b,color:#333
  style G1 fill:#dc2626,color:#fff
  style G2 fill:#dc2626,color:#fff

所以 grow 的常见成本是一次页表操作,而不是一次 memcpy。只有当增长超出预留区间(或宿主根本没做预留)时,才退化为"重新分配 + 搬运旧数据",那才是与当前总大小成正比的开销。Wasmtime 里那条退化路径就是 MallocMemory——它用 Vec 装线性内存,因此只有在 memory_reservation 被显式调成 0 时才可用,同时还要求保护页大小为 0、不启用 CoW 镜像(📄 crates/wasmtime/src/runtime/vm/memory/malloc.rs:31-39——三个条件任一不满足就 bail!,其中预留那条的错误信息说的是"malloc 内存只在没有预先预留时才可用")——注意因果方向是"预留为 0 才能用 malloc 后端",不是"malloc 后端把预留改成 0"。

⚠️ 但对 JS 侧来说,grow 的代价始终存在且必须处理:不管底层有没有搬数据,memory.buffer 都会换成一个新的 ArrayBuffer,旧的被 detach。这就是上一节那套缓存失效检测存在的理由。

工程上的结论不变——避免在热路径上频繁 grow,但理由不是"复制很贵",而是:每次 grow 都让 JS 侧所有缓存视图失效、都要重新走一遍页表操作、并且在超出预留区时会突然变得很贵。

3.11.2 实战策略:预分配

// 反模式:让 Vec 自然增长
fn process(items: &[Item]) {
    let mut buf = Vec::new();
    for item in items {
        buf.push(transform(item));  // 多次扩容,可能触发多次 memory.grow
    }
}

// 正确:预分配
fn process(items: &[Item]) {
    let mut buf = Vec::with_capacity(items.len());  // 一次分配到位
    for item in items {
        buf.push(transform(item));
    }
}

更激进的优化:在模块初始化时预分配大内存,避免热路径上的 grow:

#[wasm_bindgen]
pub fn init() {
    // 预分配 64MB——一次性付清 grow 开销
    let huge: Vec<u8> = Vec::with_capacity(64 * 1024 * 1024);
    std::hint::black_box(&huge); // 防止优化器把这次分配整个删掉
    drop(huge); // 线性内存不会缩回去,这 64MB 留在分配器的空闲区里供后续复用
}

⚠️ 这里要 drop,不能 mem::forget:forget 等于把这 64MB 永久泄漏,分配器再也用不上它,后面的分配照样要 grow。Rust 1.96.0 + Node 24 实测:drop 版之后再分配 32MB,内存停在 1042 页不动;forget 版之后再分配 32MB,内存涨到 1555 页。

3.11.3 零初始化保证

WASM 规范保证:新申请的内存页必须是全零。这是安全前提——否则攻击者可能读到上一个 WASM 实例的残留数据。

但保证只对"刚 grow 的页"生效。如果 Rust 代码 dealloc 后 alloc,复用的内存不保证是零——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 转换为返回空指针:

use std::alloc::{Layout, alloc_zeroed};

// 注意 wasm32 上 usize 只有 32 位,写 8 GiB 会在编译期报算术溢出
let layout = Layout::from_size_align(1 << 30, 16).unwrap(); // 1 GiB
let ptr = unsafe { alloc_zeroed(layout) };
if ptr.is_null() {
    // OOM——优雅处理
    return Err("内存不足".to_string());
}

但 Rust 的 Vec::push 在 OOM 时直接 abort——不像 C++ 那样抛异常。这导致 WASM 模块在内存压力下整个挂掉,而不是返回错误。生产代码应该用 try_reserve 主动检查:

let mut buf: Vec<u8> = Vec::new();
match buf.try_reserve(1 << 30) {
    Ok(()) => { /* 成功,可以 push */ }
    Err(_) => { /* OOM,业务侧处理 */ }
}

3.11.5 内存清理的工程纪律

WASM 没有 GC——线性内存只增不减。即使 drop 掉一个大 Vec,内存也只回到分配器手里,不归还给宿主。长期运行的 WASM 模块(服务器端、Web Worker 长任务)必须主动管理:

策略 实现 适用
池化重用 预分配大 buffer,循环复用 高频固定大小数据
实例重启 定期销毁 Store + 新建 服务器端,借助 Wasmtime
进程隔离 Worker + 周期重建 浏览器端长任务
内存上限 resource_limiter 配置 防止单实例失控

服务端常见的做法是让实例短命——按请求或按批次新建实例、用完即弃,内存随实例一起回收,不给线性内存累积的机会。

3.12 内存对齐与缓存局部性

WASM 内存性能的微观决定因素是 CPU 缓存——理解线性内存与 CPU 缓存层级的交互是性能优化的基础。

3.12.1 缓存层级与 WASM 内存

graph LR
  A["CPU 核"] --> B["L1<br/>最小最快"]
  B --> C["L2"]
  C --> D["L3"]
  D --> E["DRAM<br/>最大最慢"]
  F["WASM 线性内存<br/>= 一段连续 ArrayBuffer"] -.映射到.-> E
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333
  style E fill:#dc2626,color:#fff

WASM 的线性内存对 CPU 缓存来说就是宿主进程里一段连续的虚拟地址区间——和原生程序相同的缓存行为,只是可能多出边界检查的开销。访问局部性好的代码(顺序访问、stride < 缓存行)能充分利用缓存,差的代码(随机访问、跨页跳跃)频繁落到 DRAM。

3.12.2 数据布局:SoA vs AoS

WASM 中数据布局的影响和 C/C++ 一样关键。两种布局的对比:

// 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,...] ...
graph TB
  subgraph "AoS 访问模式"
    A1["x += vx * dt"] --> A2["读 x0、vx0 → 缓存行里还有 4 个字段<br/>这一轮用不到<br/>约 2/3 带宽浪费"]
  end
  subgraph "SoA 访问模式"
    B1["x += vx * dt"] --> B2["读 x0 → 缓存行包含 x1,x2,...,x15<br/>都是要用的数据<br/>vx 数组同理"]
  end
  style A2 fill:#dc2626,color:#fff
  style B2 fill:#10b981,color:#333

以 100 万粒子的 x += vx * dt 为例:AoS 布局下,每取一个 x 都要拉进一整条缓存行,而这条线上另外五个字段这一轮根本用不到——带宽被白白浪费掉大半;SoA 下 x[] 是连续的,同一条缓存行里的每个 f32 都会被用上,并且这个形状还能直接喂给 SIMD 一次处理多个通道。

WASM 项目里,性能敏感的数据结构应该优先 SoA。具体收益取决于结构体有多少字段、每轮真正用到几个——字段越多、用到的越少,差距越大。第 10 章会给出可复现的测量方法,不要照抄别处的倍数。

3.12.3 字段对齐与 padding

#[repr(C)] 决定字段顺序和 padding:

// 反模式:字段顺序差
#[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,补齐到对齐(8)的整数倍
}
// sizeof(Good) = 16

100 万个 Bad 比 Good 多占 8MB——影响缓存行命中率。Rust 默认 #[repr(Rust)] 会自动重排字段优化对齐——只有 #[repr(C)] 才严格按声明顺序。

3.12.4 缓存行与 false sharing

CPU 缓存按缓存行管理,常见的 x86/ARM 核心是 64 字节一行。两个变量在同一缓存行上时,多线程修改可能触发"伪共享"(false sharing)——即使逻辑上无依赖:

// 反模式:两个原子变量在同一缓存行
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-wasip1-threads 或浏览器的 SharedArrayBuffer)下 false sharing 同样成立——两个 Worker 各自操作逻辑上无关的 atomic,只要落在同一条缓存行上,缓存一致性协议就会让它们互相拖累。性能敏感的并发数据结构必须考虑 padding。

3.12.5 顺序访问与预取

CPU 的硬件预取器能识别连续访问模式——提前把缓存行加载到 L1。WASM 的访问模式同样受益:

graph LR
  A["顺序 stride=4"] --> A1["预取器识别<br/>提前加载下一行<br/>几乎全部命中"]
  B["小步长 stride=8"] --> B1["仍在一条缓存行内<br/>大部分命中"]
  C["跳跃 stride≥缓存行"] --> C1["每次都跨行<br/>预取收益骤降"]
  D["随机访问"] --> D1["预取器无从识别<br/>基本每次都到 DRAM"]
  style A1 fill:#10b981,color:#333
  style B1 fill:#6366f1,color:#fff
  style C1 fill:#f59e0b,color:#333
  style D1 fill:#dc2626,color:#fff

工程含义:算法的访问模式决定缓存命中率。同样是 O(n) 的算法,顺序遍历和随机遍历的实际速度可能相差悬殊——一旦数据量超过 L3 缓存,随机访问几乎每次都要付一次 DRAM 往返。渐进复杂度相同不等于实际耗时相同。

WASM 中没有原生的预取指令(如 x86 的 prefetcht0)——只能依赖硬件预取。这意味着设计算法时应该明确"我的访问模式是什么",让硬件预取能识别。

3.12.6 优化清单

flowchart TD
  A["WASM 内存性能优化"] --> B["1. SoA 优于 AoS<br/>对热数据"]
  A --> C["2. 字段从大到小<br/>减少 padding"]
  A --> D["3. 顺序访问 > 随机"]
  A --> E["4. 多线程加 padding<br/>避免 false sharing"]
  A --> F["5. 预分配减少 grow"]
  A --> G["6. 局部性 > 全局优化"]
  style A fill:#6366f1,color:#fff

每条都是基础但容易被忽视。

3.13 WASM 内存模型与原生平台的差异

WASM 的内存模型在表面上和原生(Linux/macOS/Windows)相似——都是字节数组+地址寻址。但深入到细节,差异显著影响编程模式和性能预期。理解这些差异有助于把"原生经验"正确迁移到 WASM。

3.13.1 五个根本性差异

graph TD
  A["WASM vs 原生内存模型"] --> B["地址空间"]
  A --> C["内存权限"]
  A --> D["分配策略"]
  A --> E["内存映射"]
  A --> F["资源回收"]
  B --> B1["原生: 64 位虚拟地址<br/>WASM: 32 位线性内存"]
  C --> C1["原生: 读/写/执行可分离<br/>WASM: 数据/代码完全隔离"]
  D --> D1["原生: 任意地址<br/>WASM: 顺序增长"]
  E --> E1["原生: mmap 灵活<br/>WASM: 单连续 ArrayBuffer"]
  F --> F1["原生: munmap/free 立即释放<br/>WASM: 线性内存只增不减"]
  style B fill:#6366f1,color:#fff
  style F fill:#dc2626,color:#fff

每个差异的工程影响:

3.13.2 差异一:地址空间大小

维度 原生 64 位 WASM 32 位 WASM64
地址宽度 64 位虚拟地址 32 位线性地址 64 位线性地址
规范上限 由硬件页表层数决定 2162^{16} 页 = 4 GiB 远高于 4 GiB,具体由运行时配额定
实际可用 受 OS 与物理内存限制 浏览器给的往往远小于 4 GiB 服务端可调(Wasmtime 44 默认就开着,见 §3.10.1;浏览器侧支持才是短板)

工程影响:处理大数据集(基因组、视频帧、ML 权重)时,4GiB 限制可能撞墙。memory64 能缓解,但工具链和浏览器支持都还没铺开——生产中更现实的做法是分块处理。

3.13.3 差异二:内存权限模型

原生平台有 RWX 三种权限:

// 原生: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 差异三:分配策略

graph TB
  subgraph "原生 mmap"
    A1["进程地址空间"] --> A2["稀疏分配"]
    A2 --> A3["指定虚拟地址"]
    A3 --> A4["地址 0x0 ~ 0x7fffffffffff"]
  end
  subgraph "WASM 线性内存"
    B1["WASM 模块"] --> B2["从 0 开始顺序增长"]
    B2 --> B3["memory.grow 添加新页"]
    B3 --> B4["地址连续,不能跳跃"]
  end
  style A1 fill:#6366f1,color:#fff
  style B1 fill:#10b981,color:#333

原生平台可以用 mmap 在任意虚拟地址分配——稀疏地址空间是普遍的。WASM 必须从 0 开始顺序增长——所有数据在一个连续 buffer 中。

工程影响:

  • 碎片管理更难:原生分配器可以"在远处分配大块",WASM 必须在线性内存内压缩
  • 无法 unmap 释放:原生 munmap 立即归还内存给 OS,WASM Vec::clear 不归还
  • 预分配更重要:避免频繁 grow,预分配大 buffer 是常见模式

3.13.5 差异四:内存视图

原生平台用 mmap 把文件、设备、共享内存映射为内存——零拷贝读取:

// 原生: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 + 复制到线性内存:

// WASM:必须复制
let mut buf = Vec::new();
File::open("data.bin")?.read_to_end(&mut buf)?;
// buf 在线性内存中,从文件复制了一份

工程影响:处理大文件时,WASM 必须分块读取——不能像原生那样"映射整个文件按需访问"。这影响数据库、文件解析器等场景。

3.13.6 差异五:内存回收语义

flowchart LR
  A["内存释放"] --> B["原生 free()"]
  A --> C["WASM Vec::drop"]
  B --> B1["视分配器策略<br/>可能 munmap/trim"]
  B --> B2["RSS 可能下降"]
  B --> B3["大块往往直接归还系统"]
  C --> C1["分配器内部标记空闲"]
  C --> C2["线性内存大小不变"]
  C --> C3["供后续分配复用"]
  style B fill:#10b981,color:#333
  style C fill:#f59e0b,color:#333

原生 free 后内存可能立即归还 OS(取决于分配器策略)——top 命令看到 RSS 下降。WASM 的 Vec::drop 只是标记空闲——线性内存大小不变。

工程影响:

  • 长期运行的 WASM 服务必须主动管理:用对象池、定期销毁实例
  • 监控指标不同:原生看 RSS,WASM 看 memory.buffer.byteLength
  • OOM 表现不同:WASM 除了宿主内存,还多一道硬上限——声明的 max 页数、wasm32 的 4GiB 和宿主配额,撞上就是 memory.grow 返回 -1

3.13.7 工程实践对照

graph TD
  A["WASM 工程实践<br/>vs 原生不同"] --> B["1. 大数据分块<br/>不能映射整个文件"]
  A --> C["2. 预分配优先<br/>避免 memory.grow"]
  A --> D["3. 池化重用<br/>对象池管理生命周期"]
  A --> E["4. 监控线性内存<br/>而非 RSS"]
  A --> F["5. 不能动态 JIT<br/>所有代码模块加载时确定"]
  style A fill:#6366f1,color:#fff

每条都是真实的工程模式调整。从原生迁移到 WASM 时,这些差异决定了哪些代码可以直接编译、哪些需要重构。

3.13.8 何时这些差异最显著

flowchart TD
  A["从原生迁移到 WASM"] --> B{"是否有以下场景?"}
  B --> C["大文件 mmap"]
  B --> D["JIT 编译/动态代码"]
  B --> E["内存映射 IO"]
  B --> F["原生信号处理"]
  B --> G["稀疏地址空间"]
  C --> H["重要:必须改分块"]
  D --> H
  E --> H
  F --> I["WASM 不支持<br/>必须重写"]
  G --> J["设计上避免"]
  style I fill:#dc2626,color:#fff
  style H fill:#f59e0b,color:#333

如果代码不依赖以上特性——WASM 化是平滑的。依赖任何一项都需要重新设计——这也是 §12.16 介绍的"困难移植"场景的根因。

3.14 表与函数指针的实战工程模式

§3.3 介绍了表的基础概念——但真实业务中如何用表实现 vtable、插件系统、动态分发是更具体的工程问题。这里展开 4 类实战模式。

3.14.1 模式一:trait object 的 vtable

Rust 的 Box<dyn Trait> 在 WASM 中通过表实现:

graph TD
  A["Box<dyn Trait>"] --> B["fat pointer<br/>(data, vtable)"]
  B --> C["data: 内存中的对象"]
  B --> D["vtable: 表索引"]
  D --> E["WASM 表"]
  E --> F["call_indirect(vtable[N])"]
  style E fill:#10b981,color:#333

实际生成的代码:

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 含表索引 N
  • vtable 前三项是 drop_in_place、size、align,方法从第 4 项起;所以 a.name() 是先 i32.load offset=12 从 vtable 里取出表索引,再 call_indirect(Rust 1.96.0 --release 实测,wasm32 上每项 4 字节)

每次 trait 方法调用都是 call_indirect——表查找 + 类型验证 + 跳转。

3.14.2 模式二:插件系统的函数注册

WASM 模块作为插件时,host 通过表分发调用:

// 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 比直接调用慢——因为要做表查找 + 类型检查。性能敏感场景的优化:

graph LR
  A["性能优化"] --> B["1. 热点改直接调用<br/>match 分派到已知函数"]
  A --> C["2. 单态化代替<br/>编译期确定调用"]
  A --> D["3. 减少间接调用次数<br/>批量处理"]
  style A fill:#6366f1,color:#fff

具体手段:

  • 热点改直接调用:目标只有少数几个时,用 match 分派到具体函数,编译器生成的是直接 call
  • 单态化:能在编译期决定的调用,用泛型而不是 trait object
  • 减少次数:一次间接调用处理一批数据,而不是每个元素调一次

3.14.4 模式四:函数指针的元编程

WASM 的表让 Rust 代码能存储和传递函数指针:

use std::sync::atomic::{AtomicUsize, Ordering};
use wasm_bindgen::prelude::*;

type Handler = extern "C" fn(i32) -> i32;

static HANDLERS: [AtomicUsize; 2] = [AtomicUsize::new(0), AtomicUsize::new(0)];

extern "C" fn double(x: i32) -> i32 { x * 2 }
extern "C" fn negate(x: i32) -> i32 { -x }

/// 把函数指针的值(即表索引)交给 JS
#[wasm_bindgen]
pub fn handler_ids() -> Vec<usize> {
    vec![double as Handler as usize, negate as Handler as usize]
}

#[wasm_bindgen]
pub fn register_handlers(h1: usize, h2: usize) {
    // h1, h2 是表索引,必须来自 handler_ids()
    HANDLERS[0].store(h1, Ordering::Relaxed);
    HANDLERS[1].store(h2, Ordering::Relaxed);
}

#[wasm_bindgen]
pub fn dispatch(idx: usize, arg: i32) -> i32 {
    let table_idx = HANDLERS[idx].load(Ordering::Relaxed);
    // 安全前提:table_idx 确实来自某个 Handler;随便传一个整数是 UB
    let func: Handler = unsafe { std::mem::transmute(table_idx) };
    func(arg)
}

这种元编程能力让 Rust 代码可以构造"运行时确定的调用图"——例如根据配置动态选择算法实现。

3.14.5 表的安全约束

graph TD
  A["WASM 表的安全约束"] --> B["1. 类型签名匹配"]
  A --> C["2. 索引必须在边界内"]
  A --> D["3. 元素必须初始化"]
  B --> B1["call_indirect 包含期望签名<br/>不匹配触发 trap"]
  C --> C1["越界 → trap"]
  D --> D1["未初始化的索引 → trap"]
  style A fill:#10b981,color:#333

每个约束都被 WASM 验证器和运行时强制——这让 trait object / 插件系统在 WASM 中比 C 的函数指针更安全。即使有 bug,最坏情况是 trap 而非任意代码执行。

3.14.6 表的生产模式总结

场景 模式 优势
trait object Rust 自动用表 类型安全的动态分发
插件系统 显式 export 表 host 灵活分发
元编程 usize 表索引传递 运行时构造调用图
性能优化 小表 + 单态化 减少 call_indirect 开销

3.14.7 表与 Component Model 的关系

组件模型引入了 resource 类型——某种程度上替代了"用表索引传递句柄"的模式:

// MVP:用表索引
fn create() -> i32 {  }
fn invoke(handle: i32) { /* 通过表索引调用 */ }

// 组件模型:用 resource
resource handler {
    constructor();
    invoke: func();
}

resource 比表索引更类型安全、更易理解。它在 Canonical ABI 里落地为一个 i32 句柄,指向组件运行时为每个组件实例维护的句柄表(§14.9)——那是运行时内部的数据结构,不是 WASM 的 table;组件内部的 Rust 代码做 dyn Trait 调用时照样用 call_indirect。

3.14.8 表的使用反模式

graph TD
  A["表的反模式"] --> B["1. 什么都走间接调用<br/>热点也不例外"]
  A --> C["2. 跨实例共享表<br/>类型签名易失配"]
  A --> D["3. 用表替代结构体<br/>过度抽象"]
  style A fill:#dc2626,color:#fff

每条都是真实坑:

  • 滥用间接调用:每次 call_indirect 都要付表查找和签名检查,热点路径上能直接调用就直接调用
  • 跨实例共享:表可以导出/导入给别的实例共用,但各方对索引布局和签名的约定一旦不一致,就只能在运行时以 trap 暴露
  • 过度抽象:能用直接调用就别用表,可读性优先

把表当作"必要时的工具"而非"统一抽象"——这是健康的工程态度。

3.15 内存模型与并发安全

§3.5 介绍了 SharedArrayBuffer 基础——但深入到并发安全(指令重排、内存可见性、happens-before),WASM 有自己的形式化模型。理解这套模型是写出正确多线程代码的基础。

3.15.1 WASM 内存模型的设计原则

graph TD
  A["WASM 内存模型"] --> B["弱内存模型<br/>类似 ARM"]
  A --> C["原子操作"]
  A --> D["显式同步"]
  A --> E["顺序一致原子"]
  B --> B1["允许指令重排"]
  C --> C1["i32.atomic.* 指令"]
  D --> D1["atomic.fence"]
  E --> E1["默认 atomic 操作是 SeqCst"]
  style A fill:#10b981,color:#333

WASM 选择弱内存模型——比 x86 的 TSO 模型更弱,但更通用(支持 ARM/RISC-V)。开发者必须显式同步而非依赖硬件保证。

3.15.2 原子操作的内存序

WASM 的所有原子指令(i32.atomic.load, i32.atomic.store, i32.atomic.rmw.add 等)都是顺序一致(SeqCst)——这与 C++ atomic 的默认行为一致:

use std::sync::atomic::{AtomicU32, Ordering};

let counter = AtomicU32::new(0);
counter.store(1, Ordering::SeqCst);  // 开了 atomics 时编成 i32.atomic.store
let v = counter.load(Ordering::SeqCst);

WASM 不支持比 SeqCst 更弱的内存序——线程提案原文:"Currently all atomic memory access instructions are sequentially consistent. Instructions with other memory orderings may be provided in the future."(📄 threads 提案 Overview)。这意味着开了 atomics 时,Rust 侧写 Ordering::Relaxed、Acquire、Release 在编译到 WASM 后统统落到同一批顺序一致的原子指令上(没开 atomics 时则如 §3.5 所说,连原子指令都不生成)——代码里的内存序标注仍然是有意义的文档和跨平台约束,但在 WASM 上不会换来更快的指令。

3.15.3 happens-before 关系

sequenceDiagram
    participant T1 as 线程 1
    participant Mem as 共享内存
    participant T2 as 线程 2
    T1->>Mem: data = 42(普通写)
    T1->>Mem: ready = true(atomic store)
    Note over T1,T2: happens-before 边界
    T2->>Mem: ready 是 true?(atomic load)
    T2->>Mem: 读 data → 保证看到 42

关键规则:如果某个 atomic load 读到了某个 atomic store 写入的值,那么该 store 之前的所有普通写操作,对这个 load 之后的读操作都可见。这是 happens-before 的核心保证。

3.15.4 Atomics.wait / notify

WASM 提供了"在 atomic 上等待和唤醒"原语:

// core::arch::wasm32 里的对应内建函数(1.96.0 仍是 unstable:feature `stdarch_wasm_atomic_wait`)
use core::arch::wasm32::{memory_atomic_notify, memory_atomic_wait32};

// 一个线程等待:*ptr == expected 时睡眠,timeout_ns 为负表示不超时
unsafe { memory_atomic_wait32(ptr, expected, timeout_ns) };

// 另一个线程唤醒最多 count 个等待者
unsafe { memory_atomic_notify(ptr, count) };

这是实现 mutex / condvar 等同步原语的基础。⚠️ 前提是模块编译时开启了 atomics target feature(例如 wasm32-unknown-unknown 加 -C target-feature=+atomics,+bulk-memory 并用 nightly -Z build-std 重编 std,或用 wasm32-wasip1-threads)。没开启时,std::sync::Mutex 走的是单线程退化实现,根本不会生成等待指令——这时候你的"多线程代码"其实是串行的。

3.15.5 false sharing 问题

graph TD
  A["false sharing"] --> B["原因"]
  A --> C["影响"]
  A --> D["修复"]
  B --> B1["两个变量在同一缓存行"]
  C --> C1["写一个变量影响另一个的缓存"]
  D --> D1["padding 到独立缓存行(64 字节)"]
  style A fill:#dc2626,color:#fff

WASM 的多线程下 false sharing 影响显著(§3.12 已讨论)。修复 padding:

#[repr(align(64))]
struct PaddedAtomic(AtomicU64);

struct Counters {
    a: PaddedAtomic,  // 独占 64 字节缓存行
    b: PaddedAtomic,  // 独占 64 字节缓存行
}

3.15.6 死锁的 WASM 表现

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 永久挂起,而且没有任何报错,只是一直不返回。

调试死锁需要:

  • 设置 Atomics.wait 超时(不要 Infinity)
  • 使用 try-lock 模式而非 lock
  • 监控线程状态

3.15.7 lock-free 算法

WASM 支持 lock-free 算法——但实现复杂度高:

use std::ptr;
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 测试并发代码

#[cfg(test)]
mod tests {
    use std::sync::Arc;
    use std::sync::atomic::{AtomicU32, Ordering};

    #[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);
    }
}

注意:这段测试要真正并发起来,需要 wasm32-wasip1-threads 这类带线程支持的 target,或浏览器里的 SharedArrayBuffer + Worker——默认的 wasm32-unknown-unknown 上 std::thread::spawn 是跑不起来的。

3.15.9 形式化验证

WASM 是少数在设计阶段就给出完整形式化语义的主流指令集——规范本身用形式化记法写成,社区还有 Isabelle/HOL(WasmCert)、Coq(WasmCert-Coq)、K Framework(KWasm)等多套机械化形式化:

graph TD
  A["WASM 形式化验证"] --> B["规范正文<br/>形式化记法 + 散文双写"]
  A --> C["机械化语义<br/>Isabelle / Coq / K"]
  A --> D["类型系统健全性<br/>已被机械化证明"]
  A --> E["运行时实现<br/>差分测试 + fuzz"]
  style A fill:#6366f1,color:#fff

这种严谨性让 WASM 的语义没有 C/C++ 标准里那样的大片未定义行为,行为更可预测。

3.15.10 工程实践清单

flowchart TD
  A["WASM 并发编程"] --> B["1. 优先用高级原语<br/>Mutex/Channel"]
  A --> C["2. atomic 用 SeqCst(默认)"]
  A --> D["3. false sharing padding"]
  A --> E["4. 死锁防御<br/>超时 + try-lock"]
  A --> F["5. lock-free 仅在性能临界"]
  A --> G["6. 充分测试 + fuzz"]
  style A fill:#10b981,color:#333

3.16 跨专栏关联:与 RAG 检索的对照

本专栏讨论的 WASM 线性内存模型——一段连续字节数组,通过偏移量直接寻址——和传统数据库的存储模型有结构上的相似性。在《RAG 工程与检索系统设计》专栏第 12 章"稀疏检索"中,倒排索引的 posting list 本质上也是一种"线性存储 + 偏移寻址"的结构——倒排列表在磁盘/内存中连续排列,通过偏移量随机访问。两者的设计哲学一致:牺牲灵活性(不支持任意指针跳转),换取可预测的访问模式和简单的边界检查。

下一章看虚拟机如何处理 WASM 二进制——从解码、验证、编译到执行的完整流水线。