Skip to content

第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)

页大小固定为 64KB2162^{16} 字节)——这是 WASM 规范硬编码的值,不可配置。这个数字的选择有历史原因:WASM 设计初期参考了多种平台的页大小(x86 的 4KB、ARM 的 4KB/16KB/64KB),最终选择了 64KB 作为折中——足够大以减少 memory.grow 的调用频率,又不会浪费太多空间。

初始页数是必须声明的,最大页数可选。如果指定了最大页数,memory.grow 指令在达到上限后返回 -1(即 0xFFFFFFFF,以无符号解释);如果没有指定最大页数,理论上可以增长到 2322^{32} 页(即 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

对齐参数 alignlog2\log_2 表示:align=2 表示 4 字节对齐(22=42^2 = 4),align=0 表示 1 字节对齐。对齐是提示而非约束——未对齐的访问仍然合法,只是可能在某些平台上更慢。验证器只检查 align 值不超过操作数自然对齐的 log2\log_2(例如 i32.loadalign 不能超过 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.load4 字节i32自然宽度加载
i64.load8 字节i6464 位加载
i32.load8_s1 字节i32加载 i8,符号扩展到 i32
i32.load8_u1 字节i32加载 u8,零扩展到 i32
i32.load16_s2 字节i32加载 i16,符号扩展到 i32
i32.load16_u2 字节i32加载 u16,零扩展到 i32
i64.load32_s4 字节i64加载 i32,符号扩展到 i64
i64.load32_u4 字节i64加载 u32,零扩展到 i64

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

内存增长

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

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

增长成功时返回旧页数(不是新页数),失败返回 -1(即 0xFFFFFFFF 以无符号解释为 23212^{32}-1)。失败的原因只有两种:达到最大页数限制,或宿主操作系统拒绝分配(内存不足)。

一个关键细节:memory.grow 不会重新定位已有数据。新页追加在当前内存的高端,原有内容不受影响。这和 mremaprealloc 可能移动内存的行为不同——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 0

WASM 规范中函数的参数和返回值通过抽象值栈传递(不是线性内存中的栈),但局部变量和帧指针在线性内存的栈区中。这种分离意味着: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_alloctalc 是合理选择;对于计算密集场景,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;

COUNTERGREETING 会被放入数据段,在实例化时写入线性内存的固定偏移。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 规范中唯一合法存储引用类型(funcrefexternref)的数据结构。

为什么需要表

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

元素类型编码:

类型说明
0x70funcref函数引用,可 null
0x6Fexternref外部引用(宿主对象),可 null

funcrefexternref 都是引用类型(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 的验证步骤:

  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_indirecttype 立即数一致,否则 trap(Indirect call type mismatch)
  6. 执行函数

三层安全检查确保了间接调用的类型安全——这是 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 externref

Element 段的二进制编码有 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):

  1. 如果循环变量 i 的范围可以静态推导(比如 i < len),且 len <= memory_size - access_width,则循环内的 load/store 不需要检查。
  2. 如果访问的偏移是编译期常量且足够小(比如数据段的固定偏移),且模块已声明足够大的初始内存,则可以消除检查。

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 zeroi32.div_s / i32.div_u 除以零a / 0 (整数除法)
Integer overflowi32.div_s 的结果溢出 i32 范围i32::MIN / -1
Out of bounds memory accessload/store 地址超出线性内存数组越界 (无 unsafe 时由 Rust 检查拦截)
Out of bounds table accesscall_indirect 表索引超出范围dyn Trait 引用损坏
Indirect call type mismatchcall_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 规范不定义调度——调度由宿主决定。

实际使用中需要注意:

  1. COOP/COEP 安全头:浏览器要求页面设置 Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp 才允许使用 SharedArrayBuffer——这是 Spectre 缓解措施。
  2. 分配器线程安全dlmalloc 默认不是线程安全的,需要配置 global_allocator 使用线程安全版本(加锁),否则多个 Worker 同时调用 alloc 会导致数据损坏。
  3. 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。这和原生平台上的缓冲区溢出形成鲜明对比——原生平台上,越界写可能覆盖相邻的堆元数据,导致任意代码执行。

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

  1. 逻辑漏洞:如果 Rust 代码错误地暴露了 memory 导出,JS 可以读写整个线性内存——包括栈区和全局区。这不是沙箱的 bug,而是接口设计的疏忽。
  2. 侧信道攻击:WASM 的共享内存可能被用于 Spectre 类侧信道攻击——这就是浏览器要求 COOP/COEP 头的原因。
  3. 资源耗尽:WASM 模块可以调用 memory.grow 消耗大量内存——沙箱不限制资源配额,需要宿主自行实现。

3.8 内存指令的二进制编码

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

指令操作码立即数编码示例
i32.load0x28offset, align28 00 02 (offset=0, align=2)
i32.load8_s0x2Coffset, align2C 04 00 (offset=4, align=0)
i32.store0x36offset, align36 08 02 (offset=8, align=2)
i64.load0x29offset, align29 00 03 (offset=0, align=3)
f64.load0x2Boffset, align2B 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-bindgenexternref 模式下有用——告诉运行时哪些函数可能被 JS 引用,但不指定表索引。

3.10 memory64 与 multi-memory 提案

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

3.10.1 memory64 提案:突破 4GB 限制

memory64 的实际限制:

维度wasm32wasm64
地址类型i32i64
最大内存4 GBWasmtime: 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 页。表面上只是"分配内存",但内部要做:

  1. 分配新的连续 ArrayBuffer(旧的 + 新增页大小)
  2. 把旧 ArrayBuffer 内容复制到新的
  3. 更新所有指向旧 buffer 的视图(在 V8 中是 detached)
  4. 释放旧 ArrayBuffer

真实测量(V8 124,M2 MacBook):

当前内存大小grow 1 页耗时
1 MB0.05 ms
10 MB0.4 ms
100 MB4.8 ms
1 GB48 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 代码 deallocalloc,复用的内存不保证是零——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 操作:

布局耗时原因
AoS12 ms每读 1 个 x 浪费 5 个字段
SoA3.5 ms顺序读取 x[],缓存行 100% 利用
SoA + SIMD0.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) = 16

100 万个 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 GB16 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 含表索引 N
  • a.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 objectRust 自动用表类型安全的动态分发
插件系统显式 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 二进制——从解码、验证、编译到执行的完整流水线。

基于 VitePress 构建