PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
DEEP DIVE / 05

COW 实现与快照复用条件

Go slice、地址映射、页隔离与初始化基线。

本章目录

本文沿 pysolate-spine@190ea2e 的 prepared COW 实现,追踪 Guest 线性内存、Go slice、Host 虚拟地址和 Linux 页映射之间的关系,最后讨论可复用条件与失败情况。

先给结论:

基线保存干净初始化完成后的 Guest 线性内存字节。每一轮仍创建新的 api.Module、新的 Host 运行状态和新的地址范围;只有未写入的线性内存页可以由 Linux 与基线共享,写入时才在当前映射中私有化。

安全复用要同时满足三件事:

  1. 快照时机正确:快照必须位于初始化完成、用户请求开始之前。
  2. 状态边界完整:所有会影响下一轮执行的状态,要么在被捕获的线性内存中,要么在创建新实例时重新建立;不能假设 mmap 会恢复 globals、tables、Host 资源或外部副作用。
  3. 地址与生命周期正确:Wazero 已经持有线性内存的 []byte 和地址,COW 必须在这段由本实例拥有的地址范围中原地换映射,并在模块关闭时解除映射。

1. 四个“地址”和一份 []byte

1.1 从 Guest 偏移追到 Host 地址

Wasm 线性内存对 Guest 来说是一段从偏移 0 开始的字节数组。Guest 代码里的地址通常是这个数组内的偏移,例如 ptr=4096 表示第 4096 个字节,不表示 Go 地址 0x1000。

Wazero 在 Host 一侧需要为这段线性内存找一个实际的虚拟地址。可以把对象关系画成:

Guest 看到的地址:      offset 0 ... offset size-1
                              │
                              ▼
Wazero MemoryInstance:  Buffer []byte  (ptr, len, cap)
                              │
                              ▼
Host 虚拟地址:          base ... base+size-1
                              │
                              ▼
Linux 页表:             每个系统页分别指向文件页或匿名私有页

Go slice 用一个值描述字节视图,概念上包含:

指针 ptr + 可见长度 len + 可用容量 cap

因此,下面两种情况完全不同:

// 普通 Go 语义:两个 slice 可能指向同一份可写存储。
a := backing[:n:n]
b := backing[:n:n]
a[0] = 99 // b[0] 也会看到 99

这次赋值复制了 slice 描述符,两边仍指向同一份字节。若 A、B、基线都指向同一个可写数组,A 的写入就会直接改掉 B 和基线。

本项目的 COW 则是另一条链:A、B 各有自己的 Host 地址范围,但这两个范围都可以映射到同一个封存的 memfd。两个 []byte 的指针值可以不同;每个实例只要求自己的映射在 attach 前后保持同一个地址。

1.2 Memory.Read 返回的是写穿视图

Wazero v1.11.0 的 api.Memory.Read 文档明确说,返回的 []byte 直接描述底层内存的一段区间。对它写入会写穿到 Wasm 内存;Wasm 更新内存也会反映到这个 slice。

这解释了两个实现选择:

  • NewPrepared 必须使用 append([]byte(nil), data...) 复制参考 Guest 的读取结果。参考模块随后会关闭,r.image 不能借用它的内存。
  • COW 的 attachBytes 可以拿 memory.Read(0, memory.Size()) 得到的 slice 起点作为 mmap 目标地址。内核在这个地址上替换映射,Go 描述符继续引用原有地址范围。

Wazero 同一份文档还提醒:内存增长后,之前取出的 view 可能不再是有效的稳定视图,需要重新读取。本文中的长期所有权不属于 Memory.Read 返回的临时 slice,而属于 cowMemory 的映射和 api.Module 的生命周期。


2. 把 Guest 状态和 Host 状态分开列出

COW 最容易被误解成“把整个运行中的解释器拍下来”。本实现没有这个承诺。要判断一个状态是否会被复用,先按所有权分层:

层 代表对象 谁拥有 是否由 COW 线性内存捕获
Runner 级 Host 状态 wazero.Runtime、编译后的 code、tools、earlyReads Runner 否;Runner 跨轮保持
实例级 Wasm/Host 状态 api.Module、ModuleInstance、engine、WASI/FS 上下文、Memory 一次 newGuest / 一轮 Run 否;每轮重新实例化
Guest 线性内存 CPython heap、Wasm 静态数据和可恢复的运行库字节 当前实例的 allocator/mapping 是;只捕获这部分字节
Wasm 非线性实例对象 globals、tables、DataInstances、element instances、模块导出关系 Wazero 的新 ModuleInstance 否;由实例化重新构造
Run 级状态 runState、Future map、取消函数、worker、prefix stream 一次 Run 否;不能跨轮复用
请求和输入 source、inputs、ABI 临时 buffer、结果 buffer 当前请求 否;恢复后再写入/执行
外部世界 Host 工具访问的数据库、文件、网络或进程副作用 Host 或外部系统 否;COW 不会撤销副作用

Runner 的注释已经把边界写出来:它持有编译代码、工具和可选的干净 image,但不持有活的用户 Guest。cow.go 的接口也只有 capture(api.Memory) 和 attach(api.Memory);没有 globals、tables、调用栈、Future 或 Host 资源的快照接口。

因此,“下一轮看不到上一轮的 Python 变量”只说明线性内存被正确隔离,不能推出“任意 Wasm 状态都能恢复”。如果某个运行库把关键可变状态放在 Host 对象、外部句柄、不可重复创建的系统资源或 Wazero 的实例对象中,单独恢复 []byte 就不够。

guest/runtime.c 也说明了为什么初始化边界必须按 Guest 具体分析。init 会建立 CPython 配置、导入 spine_bootstrap,并把 execute、prefix_begin、prefix_feed 保存到静态变量。COW 代码本身只知道 api.Memory;它不会因为看到 C 源码中的静态变量,就自动知道这些状态是否全部落在线性内存、是否需要重建、是否含有不可复用的 Host 指针。这里的安全结论来自固定 artifact 的生命周期和真实 Guest 测试,而不是来自“CPython 可以任意 fork”的一般假设。


3. full-copy 和 COW 共享同一个准备边界

NewPrepared 和 NewPreparedCOW 的存储方式不同,但它们的快照时机必须相同。

3.1 full-copy

NewPrepared 的路径是:

New
  → 创建参考 Guest
  → _initialize
  → CPython init
  → Memory.Read 整段线性内存
  → append 复制为 Runner.image
  → 关闭参考 Guest

后续实例是:

创建新 Guest
  → _initialize
  → memory.Grow 到 image 大小(必要时)
  → Memory.Write(0, image)
  → 执行请求

full-copy 路径每轮都会写入整份基线字节。它依然有用,因为它把“初始化基线能否恢复”和“Linux 是否真的共享页”分开了。

3.2 Linux COW

NewPreparedCOW 的路径是:

New
  → newCOWRuntime:创建 memfd
  → r.cow = cow
  → 创建参考 Guest(此时 cow 尚未 ready)
  → _initialize
  → CPython init
  → capture(Memory)
       → ftruncate memfd
       → Pwrite 初始化字节
       → 添加 seals
  → 关闭参考 Guest

构造器没有接收用户源码,也没有调用 Run。真实 Guest 测试还用工具调用计数检查捕获阶段没有调用 Host 工具。这个检查很关键:如果准备阶段已经执行一次请求,工具返回值、请求数据或请求造成的 Guest 写入就可能成为所谓“基线”。

捕获成功后,COW Runner 的典型状态是:

r.image == nil
r.cow != nil
r.cow.ready() == true
memfd 已有内容并已封存
参考 api.Module 已关闭

这里的 r.cow 是 Runner 级共享基线;后续每个 newGuest 得到的映射仍然是实例级资源。

平台边界也属于语义的一部分:cow_other.go 在非 Linux 构建中直接返回 prepared COW requires Linux,不会把 full-copy 静默冒充成页级 COW。调用方需要 full-copy 时可以直接选择 NewPrepared。


4. allocator 怎样预留最大范围

4.1 Wazero 的实验性接口约束

本项目通过 experimental.WithMemoryAllocator 把 allocator 放入 InstantiateModule 的 context。Wazero v1.11.0 的 experimental/memory.go定义了两个相关接口:

  • MemoryAllocator.Allocate(cap, max) 创建一个 backing LinearMemory。
  • LinearMemory.Reallocate(size) 返回长度为 size 的 []byte,并要求需要共享 backing 时地址不能改变。

这里的 cap 是建议的初始容量,max 是未来可能请求的最大长度。COW allocator 一开始就按模块声明的最大值申请 Host 地址范围:

unix.Mmap(
    -1, 0, int(maximum),
    PROT_READ|PROT_WRITE,
    MAP_PRIVATE|MAP_ANONYMOUS,
)

maximum 是最大 Wasm 页数乘 65536。返回的 linuxCOWMemory 保存整段 mapping,但 Reallocate(size) 返回:

m.mapping[:size:size(m.max)]

所以:

  • len 表示当前 Guest 可访问的线性内存长度。
  • cap 表示仍在这个实例名下保留的最大地址范围。
  • ptr 在增长前后不变。

“预留了最大范围”不等于“所有页已经占用独占 RAM”,也不等于“Guest 现在就能合法访问整个范围”。Guest 的边界仍由当前 len 和 Wasm 的 memory.size 语义控制;memory.grow 只是在最大范围内扩大可见长度。

4.2 为什么不能用一个普通 Go heap buffer

如果 allocator 返回普通 make([]byte, max),它最多能提供一块由 Go 管理的可写数组,但不能安全地让 MAP_FIXED 用文件映射覆盖它。MAP_FIXED 会替换指定虚拟地址范围的现有映射;对不是本实现自己预约和持有的范围使用它,可能覆盖 Go heap 或其他有效对象。

newLinuxCOWMemory 的顺序把风险限制在自己的所有权内:

  1. 先用匿名 mmap 预约最大范围。
  2. 把这个范围作为本实例的 backing []byte 交给 Wazero。
  3. 只在这段范围的前缀上调用 MAP_FIXED。
  4. 关闭实例时由同一个 cowMemory munmap 整个范围。

因此只能对本 allocator 持有的区域执行 remap。attachBytes 还检查了长度和系统页对齐,映射地址必须正好等于 unsafe.SliceData(data)。


5. Wasm 页、Linux 页和 COW 的实际粒度

实现里同时出现两个页大小:

名称 数值来源 用途
Wasm page 固定 65536 字节,即 64 KiB Memory.Grow 的 delta、Wasm memory shape、full-copy shape 检查
Linux page unix.Getpagesize() mmap 对齐、COW 私有化的内核页粒度、smaps 观测

不能把二者混为一谈。一个 64 KiB Wasm 页通常包含多个 Linux 基础页。Guest 仍然只能按 Wasm 规则增长;Linux 则可以对这段地址中的系统页逐页处理。

假设 A、B 都 attach 了相同的两页基线:

memfd 基线: [ Linux 页 0 = 17 ][ Linux 页 1 = 18 ] ...
                  ▲                  ▲
                  │                  │
A 的 MAP_PRIVATE ──┘                  │
B 的 MAP_PRIVATE ─────────────────────┘

A 第一次写入页 0 时,内核为 A 准备私有页并保留原内容作为复制源;B 仍读取基线页。A 再次写同一 Linux 页时,不需要每次重新复制整页。A 写入另一个 Linux 页,才会对另一个页发生相应的分离。

因此,写一个字节不意味着复制整份 Guest,也不应简单说成“复制一个 Wasm 页”。准确的说法是:写入会触发当前地址所覆盖的 Linux 页级私有化;实际页行为还受内核分页和工作负载影响。

cow_linux.go 的 captureBytes 要求 image 同时满足:

len(data) % cowPageSize  == 0
len(data) % wasmPageSize == 0

这是两种 API 的边界检查。Wasm image 需要按 64 KiB 解释,mmap 的长度和起点又必须满足本机系统页规则。

5.1 memfd 和 seals 保护什么

newCOWRuntime 用 memfd_create 创建没有普通磁盘路径的匿名文件对象,并请求允许加 seals。捕获时:

  1. ftruncate 把 fd 大小设为当前初始化内存长度。
  2. Pwrite 把参考 Guest 的字节写入 fd。
  3. 添加 F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE | F_SEAL_SEAL。

这些 seals 保护的是共享基线对象本身:它不能被缩小、扩大、写入,也不能再改变 seal 集合。MAP_PRIVATE 保护的是每个实例写入不会回写 fd;两者职责不同。

5.2 为什么只映射 image 前缀

捕获时把 fd 长度设为当前 Guest 内存长度。attach 时只用 imageSize 长度把前缀换成 memfd 映射;最大范围剩余部分仍然是最初的匿名零值 mapping:

实例最大范围: [ memfd 基线前缀 ][ 匿名零值尾部................ ]
                <------ imageSize ------>
                <------------- maximum --------------->

Guest memory.grow 扩大 Buffer 的长度后,可以看到这段匿名尾部。真实 COW 测试检查了:增长不改变起点、新页是零、超过 declared maximum 时失败。若把整个最大范围都映射成一个比文件更短的文件映射,访问文件长度之外的页可能不是正常的 Guest 越界错误,而是危险的映射故障;当前实现用匿名尾部避免把文件长度之外当成文件内容。


6. “同地址恢复”为什么是必要条件

A 和 B 可以有不同的 Host base。恢复时需要保持的是:

A attach 前的 base == A attach 后的 base
B attach 前的 base == B attach 后的 base

原因在 Wazero 的 backing contract 和执行引擎里,而不在 Python。

6.1 Wazero 保存了 backing slice

Wazero 的 internal/wasm/memory.go 在实例化时通过 allocator 得到 LinearMemory,再调用 Reallocate(minBytes) 把返回的 slice 放进 MemoryInstance.Buffer。后续增长也继续调用该对象的 Reallocate。

Wazero 的 api.Memory.Read 又直接返回这个 buffer 的 view。于是 MemoryInstance.Buffer 不只是一个临时变量;它是模块执行和 Host ABI 读写所使用的 backing。

6.2 编译引擎还保存了 base 和 length

在 wazero v1.11.0 的 wazevo 引擎里,DoneInstantiation 会调用 setupOpaque。当模块有本地 memory 时,putLocalMemory 把:

  • &mem.Buffer[0] 的 Host 地址;
  • len(mem.Buffer) 的当前长度;

写入 module context。源码见 internal/engine/wazevo/module_engine.go。内存增长后,MemoryGrown 会刷新这些元数据。

编译出来的 Guest 代码因此需要一个可用的 memory base 和长度。wazevo 的 lowering 代码也明确处理 memory grow 后重新加载 base/length,并在 base 可能变化时清掉依赖绝对地址的缓存。 lower.go 的注释说明了这种缓存关系。

6.3 MAP_FIXED 做了什么,没做什么

attachBytes 并没有替换 MemoryInstance.Buffer 这个 Go slice,也没有创建一个新的 slice 交给引擎。它取得现有 slice 的地址,然后调用:

fd + offset 0
PROT_READ | PROT_WRITE
MAP_PRIVATE | MAP_FIXED
address = 现有 backing slice 的起点
length  = imageSize

结果是:同一个 Host 虚拟地址仍然存在,但这个地址背后的物理来源从“匿名页”变成了“封存 memfd 的私有文件映射”。Wazero 已经保存的地址继续有效;下一次 Guest 读取会读到基线内容。

如果改成“另外 mmap 一段地址,再把 Go 变量指向它”,至少有三层风险:

  1. MemoryInstance.Buffer 可能仍指向旧地址。
  2. 编译引擎的 module context 可能仍保存旧 base。
  3. 旧映射、旧 slice 和新映射的释放所有权会分叉,容易出现悬空引用或泄漏。

MAP_FIXED 本身很危险,所以实现只对 allocator 预留的自有范围使用它,并检查 mmap 返回地址是否和目标地址一致。


7. 初始化快照的精确时机

这一节是 COW 安全的核心。源码中的 _initialize、CPython init、Wazero data segments 和 capture/attach 不能调换。

7.1 Wazero 实例化已经会写内存

Wazero v1.11.0 的 Store.instantiate 顺序包括:

  1. 创建 ModuleInstance、tables、globals 和 engine。
  2. 通过 allocator 创建 memory。
  3. applyData 把主动 data segments 写入 MemoryInstance.Buffer。
  4. applyElements 建立 element 状态。
  5. Engine.DoneInstantiation(),让执行引擎安装实例上下文。
  6. 若配置允许,再执行 start function。

源码见 internal/wasm/store.go。本项目用 wazero.NewModuleConfig().WithStartFunctions() 清掉自动 start function,然后显式调用导出的 _initialize。这让准备路径和每轮路径都能明确控制初始化顺序。

7.2 参考 Guest:快照在 init 之后

第一次构造 COW Runner 时,r.cow 已存在,但 ready() 为 false。于是参考 newGuest 走:

匿名最大范围
  → InstantiateModule(data segments + 新实例对象)
  → _initialize
  → 因为 COW 尚未 ready,执行 CPython init
  → capture(m.Memory())

capture 发生在 init 返回后,读取的是 CPython bootstrap 完成后的当前线性内存。它没有捕获执行中的调用栈,也没有捕获任何用户源代码。

capture 后参考模块立即关闭。COW Runner 不保留这个活实例;它只保留 memfd 和编译代码,等待后续 newGuest。

7.3 后续 Guest:先 _initialize,再 attach,跳过 init

COW image ready 后,每轮 newGuest 是:

为本实例预约匿名最大范围
  → InstantiateModule(data segments + 新的 ModuleInstance)
  → _initialize
  → attach:在原地址映射 image 前缀
  → 普通 / PLM / prefix 请求
  → Close module,Free/munmap 本实例 mapping

prepared.go 在 _initialize 成功后先判断 cow.ready(),然后调用 attach;它不会再走 CPython init。这是因为 init 的最终结果已经包含在参考 image 中。后续实例需要的是新实例的 Wazero 对象和执行上下文,再把线性内存恢复到准备状态。

7.4 为什么不能提前 attach

提前 attach 看起来像是“先把基线放进去再初始化”,但它混合了两类不应混合的写入:

  • data segments 和 _initialize 是新实例建立时的初始化写入;
  • image 是参考 Guest 在 CPython init 完成后的目标状态。

如果先 attach,再执行 _initialize,初始化代码就会在基线映射上运行。MAP_PRIVATE 会保护 memfd 不被回写,但会把初始化写过的页私有化;最终得到的是“基线 + 这次初始化的写入”,不是一份纯粹的参考 image。如果进一步试图在 Wazero 完成实例化、data segments 写入之前 attach,就会和 allocator、实例对象及实例化顺序混在一起,不能再把这个操作解释为“初始化后的恢复”。当前顺序把初始化写入放在匿名范围上,最后用 attach 覆盖 image 前缀。这样初始化产生的临时前缀内容被丢弃;匿名尾部仍保留自己的零值。full-copy 路径也遵循相同原则:先 _initialize,再 Memory.Write(0, image)。

这个顺序决定恢复后的字节内容。

7.5 shape 不一致时必须失败

attach 不会猜测不同 shape 如何合并:

  • 当前内存小于 imageSize:尝试按 Wasm 页增长到 image 大小。
  • 当前内存大于 imageSize:报 COW memory shape changed。
  • 当前长度最终必须等于 image 大小。

full-copy 也有类似规则:当前大小大于 len(r.image) 时失败;较小时增长;增长或写入失败都返回错误。

这意味着 _initialize 和 artifact 的 memory shape 是安全边界的一部分。若以后 _initialize 在新版本中多增长了一页,或者构造器使用了不同的 Wasm artifact,不能把它当成“只差一点长度”而继续恢复。


8. 一轮请求里,COW 到底在哪里生效

COW 只发生在 newGuest 的 memory 恢复阶段。它不替代 Host/Guest ABI,也不把工具调用变成共享状态。

一次普通 Run 的大致路径是:

Runner.run
  → 创建 runState(ctx、Future、worker)
  → JSON 编码 source/inputs
  → newGuest(COW allocator → _initialize → attach)
  → alloc + Memory.Write 请求字节
  → Guest execute
       → _spine.call
       → Host readRequest / invoke
       → Tool 使用 Go-owned json.RawMessage
       → writeResponse 写回本轮 Guest 内存
  → 从 Guest 读取结果
  → 在关闭 Guest 前复制/解码结果
  → release + Module.Close

几个边界要分开看:

  • source 和 inputs 是在恢复完成后才进入本轮 Guest。它们不能出现在 prepared image 中。
  • callWithBytes 使用 Guest 的 alloc 和 Memory.Write 传递临时 ABI buffer;这些写入落在当前实例的私有映射上。
  • Host tool 的参数由 readRequest 从 Guest 读取,再通过 Tool 接口在 Go 一侧处理。工具不是 Guest image 的一部分。
  • execute 返回的结果在 Runner.run 中读取后才交给外部;模块关闭前必须完成这个复制/解码,不能让调用方持有已经 munmap 的 Guest view。

PLM 和 prefix 也不会改变 COW 的边界。runState 里保存的 Future、取消函数和 worker 只属于一轮;它们不会随线性内存恢复。允许提前执行的工具必须被调用者声明为 early read,并且源码注释要求它是 snapshot read、遵守 context。一个会写数据库、删除文件或改变全局计数器的工具,即使它返回一个简单整数,也不因为结果被编码成 JSON 就变成可安全复用的快照输入。

future 的 handle 也不能跨 Run:它带有当前 runState 的 map 和取消生命周期。请求不匹配时实现会放弃预取值,改为正常调用,保证最终调用使用匹配的请求结果。这是 Host 状态隔离的例子:COW 负责 Guest memory,Future 负责另一个独立的 Run 级状态。


9. 什么条件下可以安全复用

下面的条件既包含当前代码已经表达的约束,也包含使用者必须为具体 Guest 负责证明的条件;COW 代码能检查地址、长度、映射和释放,但不能替代对 Guest/Host 语义的审查。

9.1 快照点是稳定的、无请求的初始化完成点

必须满足:

  • 已完成 Wazero 实例化和 _initialize。
  • 参考路径已完成 CPython init 或等价 bootstrap。
  • 没有执行用户 source,没有注入 inputs,没有读取 prefix stream。
  • 没有调用 Host tool;至少要能证明准备阶段不会调用会产生外部副作用的工具。
  • 没有待解析的 Future、worker 或正在运行的 Guest 调用。
  • 参考 api.Module 在 capture 后关闭,后续不再修改基线。

当前构造器之所以能满足第一组条件,是因为它的签名没有用户 source,且 NewPreparedCOW 在 newGuest 返回后才调用 capture。新增 warmup 步骤后,需要重新检查它引入的状态。

预热内容也会成为基线内容

“没有执行用户请求”不等于“内存中没有敏感或请求相关数据”。如果未来的预热步骤已经把用户数据、密钥或租户上下文写入 Guest,再在这一步 capture,COW 只会隔离后续实例之间的写入,不会自动清空基线里已经存在的字节。安全条件必须明确要求:捕获前移除或重置这些内容,或者把它们放到每轮恢复后再注入的状态中。

同样,如果预热包含 Python 或未来 NumPy 的伪随机数生成器初始化/使用,生成器的内部状态可能成为被复制的 Guest 状态;各轮就可能从相同状态开始,产生重复序列。实现者必须在每轮恢复后重新播种、显式重置,或有意接受这种可复现语义,并且要先证明该状态究竟位于可恢复线性内存还是位于需要单独重建的 Host/扩展对象中。不能把“随机数状态在初始化期间产生”误写成“COW 会自动给每轮新的随机状态”。

这条检查也适用于文件描述符、Host 回调、网络连接和其他外部资源:快照时机应位于用户数据、密钥、伪随机预热和外部资源绑定之前,除非这些状态有明确的每轮隔离/重建协议。把 fd 数字、回调索引或资源标识写在 Guest memory 里,不等于对应的 Host 资源已经被复制。当前 Spine 没有 NumPy;现有测试证明的是固定 Guest 的初始化调用路径、Python 写入隔离和错误/取消后的可继续运行,不应据此声称已实测密钥清理或伪随机状态重置。

9.2 影响执行的状态必须可重建或确实位于线性内存

安全复用不能只问“字节是否相同”,还要问“下一次执行读到的所有状态是否相同”。至少要审查:

  • Wasm mutable globals 是否在实例化时重建到预期值。
  • tables、funcref、element instances 是否由新实例建立,且不会指向上一轮对象。
  • Wazero engine/module context 是否为新实例,memory base/length 是否已安装。
  • WASI、文件系统、随机数、时间、环境变量和任何导入资源是否有明确的每轮策略。
  • Guest C/C++ runtime 的静态对象和 allocator 元数据是否能通过“新实例初始化 + 线性内存恢复”得到有效状态。
  • 线程、异步任务、信号处理器或 Host 回调是否存在快照之外的可变状态。

如果答案是“某状态在 Host 侧,而且没有重新创建或重置”,就不能声称安全复用。COW 没有通用办法把它补进来。

一个与 NumPy 章节衔接的未来例子

当前 Spine 没有 NumPy;当前 dist/spine.wasm 的边界仍是基础 CPython/WASI Guest。NumPy 构建章节记录的 numpy-core 是另一个 artifact,不能把它的内存 image 当成 Spine 的 image。因此,严禁跨不同 artifact 混用内存 image。即使两个产物使用相同的 CPython 主版本,也不能跨 artifact 混用:image 中的 Guest 偏移、分配器状态、静态数据和对象布局都依赖生成它的那份 Wasm 代码与内嵌文件。当前 attach 的 shape 检查只检查长度,不会证明两个 artifact 的语义布局相同。

假设将来有一份明确的、NumPy 已编入的 Spine artifact,并且实现者决定把“完成 import numpy 的预热状态”作为 prepared 基线,那么捕获点应是:

同一份 NumPy-enabled Spine artifact
  → 新建参考 Guest
  → _initialize / CPython init
  → import numpy 完成
  → 不执行用户请求、不调用有副作用的 Host tool
  → capture 线性内存

这只是把 NumPy import 纳入某个固定 Guest 的初始化边界;它不会让当前 Spine 自动获得 NumPy,也不会让原始 numpy-core 与当前 Spine 共享 image。

恢复或验收这份未来基线时,不能只问“ndarray 对象的某些字节是否在 image 里”。至少要重新核实四个关联层次,并把它们和当前实例联系起来:

  1. ndarray 对象:对象元数据是否属于当前实例可恢复的 Guest 状态,是否仍指向当前实例的对象/内存,而不是上一轮 Guest 或一个已经关闭的 Host 对象。
  2. shape:维数、各维长度和总元素范围是否与该对象的当前数据布局一致。shape 是元数据,不能单独证明数据缓冲已经被捕获。
  3. strides:每一维的字节步幅是否仍对应当前对象和当前数据;不能因为数组是连续或因为 shape 看起来相同,就跳过非连续视图、切片和步幅的核对。
  4. data:数据起点、可访问字节范围和所属 mapping 是否落在当前 Guest 的有效线性内存中,并且在 snapshot/restore 后仍指向正确的当前实例数据。不能凭 ndarray 对象本身在 Guest memory 里,就推出它的 data buffer 也在同一份 image 里。

如果 data 来自 Guest 线性内存,仍需检查对象元数据、shape、strides、起点和边界之间的一致性;如果它来自 Guest 之外的外部 buffer,则恢复 Guest 字节不会自动复制那块 buffer。外部 buffer 必须有独立的重建、重新绑定或拒绝复用策略。相同原则适用于文件描述符、Host 分配对象、扩展模块句柄和其他外部资源:把一个数值句柄或描述符放在数组/对象的某个字段里,不等于资源本身已经进入 snapshot。

本文不对原始 NumPy 的具体 buffer 实现作猜测;这里只把源码已经支持的 COW 条件延伸成验收问题:对象、布局元数据、数据范围、外部资源必须分别有证据。若未来 NumPy-enabled artifact 不能回答这些问题,就只能把 import numpy 当作普通初始化后重新执行,或拒绝把该状态纳入可复用基线。

9.3 新实例的初始化必须适合在 attach 前运行

当前设计要求 _initialize 在匿名 mapping 上运行,然后由 attach 覆盖 image 前缀。因此 _initialize 必须:

  • 不依赖上一轮用户写入;
  • 不进行不可撤销的外部副作用;
  • 不要求 image 已经提前存在,除非该要求被明确纳入设计;
  • 不把当前内存 shape 增长到超过 image 或 declared maximum;
  • 不留下必须在 Host 侧恢复、而代码没有恢复的隐式状态。

如果 Guest 必须执行一次每实例 init 才能建立不可替代的 Host 资源,而后续 COW 路径又跳过 init,那么当前路径就不满足该 Guest 的复用条件。不能仅因为线性内存测试通过,就把 NewPreparedCOW 扩展到这种模块。

9.4 memory shape 和映射契约稳定

当前实现依赖:

  • 模块有一个导出名为 memory 的 memory definition。
  • memory 有 declared maximum,allocator 能按最大 Wasm 页预约地址空间。
  • image 长度同时满足 Wasm 页和 Linux 页的对齐要求。
  • Reallocate 不改变 backing slice 的起点。
  • attach 的长度精确等于 image 大小,目标地址属于本实例先前预约的范围。
  • image 前缀之外仍是匿名、零初始化的尾部,不把短 memfd 映射延伸到文件末尾之外。
  • Grow 在同一最大范围中改变可见长度,并在超过 Wasm maximum 时失败。

这些条件决定映射与引用在整个使用期间是否有效。

9.5 Host 工具和外部副作用有明确的重放语义

COW 不会撤销 Host 副作用。可安全用于 prepared/early-read 的工具,至少应满足:

  • 读取结果只依赖明确的输入和可接受的外部快照;
  • 允许同一请求在准备、预取和实际执行边界被判断,而不会产生重复副作用;
  • 遵守 ctx,使取消时 runState.close 能等待 worker 结束;
  • 不保存指向已经关闭 Guest memory 的 slice;
  • 不把 Future、文件描述符、网络连接或 Host 对象句柄写入 Guest image 后期待下一轮仍可用。

当前 Tool 注释直接要求 early-read 工具是 snapshot read。工具能否提前执行,仍需要 Host 侧的语义约定。

9.6 关闭顺序和并发所有权成立

所有权关系是:

Runner
 ├─ compiled code / runtime
 ├─ tools / earlyReads
 └─ COW memfd(共享基线)

每个 api.Module
 └─ 一个 cowMemory(该实例的匿名范围 + image 前缀 mapping)

实例关闭时,Wazero 会调用实验性 allocator 的 Free;Linux 实现的 Free 最终 munmap 整个最大范围。Runner 关闭时关闭 runtime 和 COW fd。必须先等待所有 Run 返回,再关闭 Runner;当前 API 由调用方保证这一关闭顺序。

释放后的数字地址可以被进程重新使用。不能因为 /proc/self/maps 后来又出现同一个数字地址,就断言旧 mapping 泄漏;应该检查原对象已经不可用、munmap 成功、fd 已关闭、实例不再持有该 mapping。


10. 测试分别证明了什么

源码中的测试把“行为正确”“内核机制正确”和“资源回收正确”分开了。它们的证据范围不能互相冒充。

10.1 cow_linux_test.go:直接测 Linux 对象

这组测试不跑 Python,直接操作 linuxCOW 和 linuxCOWMemory:

  1. 用 captureBytes 写入基线,使用 F_GET_SEALS 回读四个 seals,确认内核实际记录的保护标志。
  2. 创建 first/second 两个不同的匿名地址范围,attach 到同一 memfd,要求各自 attach 前后地址不变。
  3. attach 后两个实例读到相同基线;first 写入两个系统页后,second 仍读到原值。
  4. 用 Pread 重新读取 memfd,确认 first 的私有写入没有改动封存基线。
  5. 增长 first 的 Wasm 可见长度,确认起点不变、匿名尾部为零。
  6. 检查零长度和不可能的 mmap 请求会失败,release 后不能再 Reallocate,重复 Free 不重复解除映射。
  7. 读取 /proc/self/smaps 的 Shared_Clean / Shared_Dirty,提供尚未私有写入时的共享映射证据。

smaps 在这些测试中用于观察页映射。它不能推出所有工作负载都能共享固定数量的物理页。

10.2 cow_test.go:接回真实 Guest

真实 COW 测试加载 dist/spine.wasm,验证:

  • 真正的 Memory.Grow 能在同一地址增长;
  • 新增的匿名页为零;
  • 超过 declared maximum 时失败;
  • COW Runner 不保留 full-copy 的 r.image,但仍保留 ready 的 r.cow;
  • 一轮写入 builtins.cow_secret,下一轮看不到;
  • Host tool、PLM、prefix、工具错误和 Guest 错误仍有原有行为;
  • timeout 后可以创建新 Guest;
  • Run 结束后不残留目标 memfd mapping,Runner 关闭后不残留目标 fd。

这些测试把内核映射和 Python 行为串起来,但仍针对仓库的固定 artifact。

10.3 prepared_test.go:full-copy 对照组

full-copy 测试检查:

  • r.image 确实存在;
  • 捕获时 calls 仍为空;
  • A 写入不会改变 B 或 r.image;
  • Python 私有状态、PLM、prefix、错误和取消都不会污染下一轮。

它证明的是“干净基线恢复和实例隔离”可以在不使用 Linux 页 COW 的情况下成立。full-copy 与 COW 得到相同业务结果,并不证明二者的物理存储或成本相同。

10.4 测试没有证明什么

即使所有这些测试都通过,仍不能推出:

  • 任意 Wasm module 都可以这样 checkpoint;
  • globals、tables、stack 和 Host 资源已经被捕获;
  • Python/C 扩展的所有外部状态都可安全复用;
  • 工具副作用会被回滚;
  • 所有基线页都常驻 RAM 或都保持物理共享;
  • COW 一定比 full-copy 更快或节省固定比例的 RSS;
  • 换一个 Guest build、Wazero 版本或 memory layout 后仍然安全;
  • 进程重启后可以仅凭 memfd 恢复这份 image。

这些测试分别覆盖当前 artifact 的页映射、执行隔离、增长和收尾。扩大 Guest 支持范围时,还需要相应的兼容性验证。


11. 常见错误和对应后果

错误改法 破坏的条件 可能结果
把 COW 写成两个 slice 指向同一个数组 写入隔离 A 改变 B 和基线
MAP_SHARED 替代 MAP_PRIVATE 实例私有化 写入可被其他映射观察,甚至改变共享对象
捕获后不加 seals 基线不可变 fd 内容或大小可被改变,后续 Guest 不再有固定基线
attach 到新地址,只替换 Go 变量 引擎地址契约 Wazero/编译代码继续使用旧 base,出现错误读写或崩溃风险
对普通 Go heap 地址使用 MAP_FIXED mapping 所有权 覆盖其他 Go 对象或有效 VMA
在 _initialize 前 attach 初始化边界 基线被初始化写入叠加,得到的不是纯 image
在用户代码或工具后捕获 干净快照时机 请求数据、Python 变量或外部结果进入所有后续实例
只把 memfd 映射到 image 长度,却把尾部也当文件页 文件长度边界 越过文件末尾可能触发严重映射故障,而不是正常 grow 失败
把 64 KiB Wasm 页当作 Linux 页 对齐和粒度 错过系统页对齐问题,错误描述 COW 成本
Grow 时返回会搬家的 slice backing 地址稳定 引擎缓存的 base 过期;旧 Host view 也失效
关闭 module 后继续使用 Memory.Read 返回的 view 实例所有权 slice 指向已解除映射的地址,成为 use-after-munmap 风险
Runner 仍有活跃 Run 时关闭 fd/runtime Runner 生命周期 后续 attach 或执行访问已关闭资源
用 COW 期待撤销 Host 工具副作用 Guest/Host 状态边界 Guest 恢复了,数据库、文件或外部计数器没有恢复

12. 源码导航与最终判断

项目侧的主链是:

  1. prepared.go:NewPreparedCOW、newGuest,定义捕获/恢复时机和三条实例化分支。
  2. cow.go:定义 cowMemory、cowRuntime 的所有权边界。
  3. cow_linux.go:newLinuxCOWMemory、captureBytes、attachBytes、Reallocate、Free 的 Linux 实现。
  4. runner.go、bridge.go、future.go、prefix.go:说明 COW 外的 Run、ABI、Host 工具、Future 和流式输入状态。
  5. guest/runtime.c:说明 _initialize 后显式 init 如何建立 CPython/bridge bootstrap。
  6. cow_linux_test.go、cow_test.go、prepared_test.go:分别检查内核机制、真实 COW Guest 和 full-copy 对照。

上游侧应固定阅读 Wazero v1.11.0 的:

最终判断可以压缩成一句话:

COW 的“共享”只发生在封存 memfd 的未写入 Linux 页上;安全复用的“恢复”只覆盖新实例的 Guest 线性内存。只有当 Guest 的其余状态可以由新实例重建、Host 没有未处理的副作用、快照确实位于干净初始化边界,并且 backing 地址和生命周期都受控时,才可以把这份 initialized memory 当作可复用基线。

如果某项状态尚未核实,可以先限制支持范围或拒绝该 Guest;等补齐重建与恢复机制后再开放。