PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
IMPLEMENTATION / 06

Linux COW:共享与隔离

虚拟内存、memfd、同地址映射与私有写入。

本章目录
配套饭点图解 · 3 节(按需展开)

先读 Go 的值与内存、内存与 ABI 和 prepared full-copy。本章把“每个 Guest 都有自己的状态”与“底层哪些物理页可以共享”分开讲。

COW 是 copy-on-write,意思是“写入时复制”。这里讨论 Linux 内核管理的内存页,不是 Python 变量的赋值,也不是 Go 的 copy 函数。

1. 从 full-copy 出发

初始化 CPython 需要准备解释器、模块和内部对象。Prepared 模式先完成一次干净初始化,再以这份状态创建后续 Guest。

Full-copy 的办法容易理解:

干净基线 → 复制全部字节 → Guest A 私有内存
         → 复制全部字节 → Guest B 私有内存

即使 A 和 B 只改了很小一部分数据,它们也先得到整份私有副本。我们保留这条路径,因为它直观,也方便和 COW 对照结果。

COW 改变的是底层存储方式:

一份只读基线
  ├─ A 的私有映射:未修改的页可与基线共享,写过的页属于 A
  └─ B 的私有映射:未修改的页可与基线共享,写过的页属于 B

A 看见自己的修改,B 仍看见原值。不能把两者指向同一个可写 Go slice,那只是共享可变数据,没有写入隔离。

2. 虚拟地址不是物理内存的位置

程序通常使用虚拟地址。操作系统和处理器协作,把虚拟地址翻译成物理内存的位置。这层翻译允许两个不同的虚拟地址引用同一份物理数据。

操作系统按页管理这类映射。页是一块连续、固定大小的地址范围。不要混淆两种“页”:

  • Wasm 的一页是 64 KiB,memory.grow 按这个单位增长。
  • Linux 的基础内存页大小由系统决定,应查询操作系统,不能把 Wasm 的 64 KiB 当成它。

同一块 Wasm 内存包含很多操作系统页。Wasm 看到一段连续字节,Linux 则可以逐页决定底层数据是共享还是私有。

还要分清 Guest 偏移与 Host 虚拟地址。Wasm 中的指针是线性内存里的偏移;Go/wazero 用当前实例的内存起点加上偏移找到字节。A 与 B 可以有不同的 Host 起点,但相同的 Guest 偏移仍指向各自对应的数据。后文的“同地址恢复”要求的是每个实例恢复前后自己的起点不变,不是要求所有实例共用同一个 Host 地址。

也要区分:

  • 虚拟地址范围:程序为内存安排了多大的地址空间。
  • 实际驻留的物理页:目前在 RAM 中承载这些地址的页。

预留一个较大的虚拟范围,不等于立即为每个字节分配独占 RAM。反过来,程序不断写入,也会逐渐付出私有物理页的成本。COW 不意味着“几乎不用内存”。

3. 写入时发生什么

先假设 A 和 B 都读取基线中的同一页。内核可以让它们共享该页的物理数据。

当 A 首次写入时,处理器发现这次写入需要操作系统介入。这类事件叫缺页异常。它不一定意味着程序出错,也可能是正常的按需分配或 COW。

对于私有 COW 写入,内核会准备 A 的私有页,保留原数据,把 A 的映射改到私有页,然后继续执行写入。

因此:

  1. A 的后续读取看到新值。
  2. B 继续看到基线中的原值。
  3. 基线本身没有被 A 改写。

通常不是每写一个字节就复制整份 Guest,也不是每次写同一页都再复制一次。粒度由内核的页管理决定,成本取决于实际访问与写入模式。

这里不声称 COW 比 full-copy 快多少,也不声称节省多少 RAM。 我们验证真实的共享与隔离机制,不把别的系统或论文数字套到本项目上。

4. memfd、文件描述符和封存

Linux 的 memfd 可以创建一个没有普通磁盘路径的、由内核管理的匿名文件对象。我们把干净基线放在其中,让多个 Guest 映射同一个对象。

文件描述符是进程里用来引用已打开资源的小整数。它不是基线内容,也不是跨进程通用的身份号码。程序需要在用完后关闭它。

匿名文件并不自动意味着只读。基线写入完成后,还需要使用 Linux 的 seals(封存约束),禁止修改内容、扩大或缩小文件,避免其他映射观察到变动的“基线”。

这是一组内核约束,不是给每次工具调用增加证书或 hash 检查。我们的 Host 工具、Future 和请求仍采用原来的小型状态结构。

memfd 也不等于“永远不能交换到磁盘”。不要把它当成额外的保密或无交换保证。

5. mmap:把同一份基线放进不同地址范围

mmap 的作用是建立一段内存映射。相关标志有不同职责:

  • MAP_PRIVATE:写入只影响当前映射,不修改文件或其他映射。这是这里需要的 COW 语义。
  • MAP_SHARED:修改可能被其他共享映射观察到,不适合充当各 Guest 的私有工作内存。
  • MAP_ANONYMOUS:先获得不由文件承载的匿名内存,适合给新实例提供干净初始空间。
  • MAP_FIXED:在指定地址建立映射,替换那个范围原有的映射。它本身不提供 COW。

MAP_FIXED 必须谨慎:若地址或长度错了,可能覆盖本进程其他有效内存。实现只能替换自己分配并持有的范围,不能拿一个普通 Go 堆对象的地址随意映射。

6. 为什么不能一开始就映射基线

一个新 Wasm 实例不是“拿到内存就立刻执行用户代码”。实例化要写入初始数据,_initialize 也会初始化运行库状态。

如果先把已初始化的基线作为它的初始内存,再运行这些步骤,就可能覆盖基线内容,或者让初始化逻辑遇到本不应提前存在的状态。

因此创建顺序是:

分配干净匿名内存
  → 实例化新的 Wasm module instance
  → 执行这个实例自己的 _initialize
  → 在原地址映射干净基线
  → 执行普通 / PLM / prefix 请求

最后一步映射时,为什么还要留在原地址?因为 wazero 内部可能已保存了对这段线性内存的引用。只在我们自己的变量里换一个 slice,不会自动更新执行引擎已经持有的地址。

这也是 COW 不只是“把 Memory.Write 改成 mmap”的原因:内存分配与恢复顺序需要一起配合。

7. 内存增长必须有明确规则

Wasm 程序可以通过 memory.grow 请求更多内存。仅仅把初始部分映射正确还不够,还要回答:

  • 增长后是否还在同一段受控地址范围里?
  • 新增的可见字节是否为零?
  • 请求超过上限时如何失败?
  • 文件长度是否足以承载被访问的映射范围?

最后一点尤其重要:映射文件以后,访问超出有效文件范围的页面可能使 Host 进程收到严重错误,而不是一个正常的 Python 异常。不能让 Guest 的一次增长把 Go Host 弄崩。

本实现先预留该 Wasm 声明的最大匿名地址范围。恢复时,只把基线覆盖的前缀换成 memfd 的私有映射;后面的范围仍是这个实例自己的匿名零值内存。memory.grow 在预留范围内增加可见长度,不搬动起点,也不把文件长度之外的地址当成文件内容访问。

当前构建产物声明初始 2048 个 Wasm 页、最大 8192 页,也就是 128 MiB 初始可见范围、512 MiB 最大范围。这些数字来自 Wasm 的 memory 声明,不是 RSS 测量或本机内存配额。超出声明上限时增长失败;不能因为预留过地址,就声称所有增长都应成功。

8. 所有权与退出

可以把资源分成两类:

  • Runner 持有编译后的代码、工具注册和共享基线。
  • 每次 Run 持有自己的 Wasm 实例、内存映射、I/O 和 Future。

一轮结束后,解除它的映射。其他 Guest 的映射仍然存在,不应被一起释放。

释放后的虚拟地址可以立刻被进程其他部分重新利用。因此,检查释放要看 munmap 是否成功、我们自己的映射与描述符是否回收,不能要求某个数值地址永远不再出现在进程的地址图里。开启 race 检查时也可能观察到这种地址复用;它本身不是 COW 泄漏。

Runner 关闭时,释放共享基线。沿用原契约:先等待所有 Run 返回,再关闭 Runner。没有为此添加新的租约系统、池控制器或并发关闭框架。

Go 的 GC 不会替代内核资源的显式释放。持有映射的 Go struct 被回收,不代表应用可以不执行 munmap;文件描述符也有自己的关闭操作。

输出必须先复制到 Host 拥有的内存,再关闭 Guest。否则 Host 留下的 slice 可能仍指向已经解除映射的地址。

9. 不会随线性内存一起恢复的东西

COW 共享的是干净的 Guest 线性内存基线,不是整个 Go 进程:

  • Host 工具注册、工具数据和 Future 不在基线里。
  • prefix 的输入通道不在基线里。
  • 任意 Wasm global、table 或外部文件描述符不会因映射线性内存而自动恢复。
  • 已经执行的外部副作用不会被“撤销”。

本项目只针对自己 Guest 在干净初始化结束后的状态使用 prepared 路径,不支持捕获任意正在执行的 Python 程序。如果以后给初始化增加新资源,必须重新审视这个边界。

10. 怎样证明不是“改了名字的 full-copy”

只看两个请求结果相同,无法区分 COW 和全量复制。应分开验证:

  1. 机制:实际通过内核映射同一份不可变基线,不是为每个 Guest 再复制全部字节。
  2. 地址:恢复前后的地址不漂移。
  3. 隔离:A 写入以后,B 和基线仍保持原值。
  4. 真实语言行为:Python 状态污染不会泄漏到下一轮,PLM/prefix 仍保留原有语义。
  5. 生命周期:错误、超时和退出后,映射与描述符能够回收,后续 Run 不受污染。
  6. 增长边界:有效增长和超限请求都按约定处理。

共享页的观察是机制证据,不自动等于端到端性能收益。若以后需要比较性能,还得控制同样的输入、准备状态和请求路径;本章不安排这种测量。

11. 运行与源码导航

在 Linux 仓库根目录运行:

make prepared-cow
go run ./cmd/demo -source examples/order.py -prepared-cow -plm -show-transformed
go run ./cmd/demo -source examples/order.py -prepared-cow -prefix

订单示例的结果是 {"item": "book", "total": 47}。-prepared-cow 选择内存创建方式,-plm / -prefix 选择执行路径,不能同时选 -prepared-copy 与 -prepared-cow。不支持 Linux COW 的平台会明确报错,不静默改用 full-copy。

这次 COW 改动在 Go Host 一侧,不需要为此修改或重新打包 Guest。它运行的仍是已有真实 CPython Wasm。

阅读顺序:

  1. NewPreparedCOW:创建参考 Guest,捕获并封存干净基线。
  2. newGuest:先分配、实例化和初始化,再选择 fresh、full-copy 或 COW 恢复。
  3. Linux 映射实现:看 newLinuxCOWMemory、captureBytes、attachBytes、Reallocate 与 Free,分别对应申请、封存、恢复、增长与释放。
  4. 内核映射测试:观察同地址、共享页、写隔离、分配失败和解除映射。
  5. 真实 Guest 测试:普通执行、PLM、prefix、Python 状态隔离、错误、取消和实际内存增长。

针对这一块运行 go test -run "TestLinuxCOW|TestPreparedCOW" -count=1 -v 即可;阶段收尾再用 make offline 跑完整离线验收。

自测

  • 为什么 Go 中两个 slice 指向同一块可写数据,不等于 COW?
  • MAP_PRIVATE 和 MAP_FIXED 分别解决什么问题?
  • 为什么新的 Wasm 实例还要先执行 _initialize?
  • 如果一个 Guest 写遍全部基线,为什么不能期待它继续享有大量共享?
  • 为什么 Future 不应放进可复用基线?
  • 为什么通过写入隔离测试,还不能宣称任意 Wasm 快照都能恢复?