PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
CODE READING / 23

Linux COW:逐步跟进系统调用

cow_linux.go

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

本章沿一次 Linux COW 实例的状态前进:先创建空的 memfd,按模块声明的最大 Wasm 页数预留实例地址范围;参考 Guest 完成初始化后,把线性内存捕获并封存;新 Guest 先完成 _initialize,再在自己的 mapping base 上 attach sealed image;增长只扩大当前可见范围;最后分别释放实例映射和 Runner 的 fd。

需要保持三个单位的区别:Wasm page 固定为 64 KiB,memory.grow 按它计数;OS page 由 unix.Getpagesize() 返回,mmap、地址对齐和私有化按它工作;Guest memory 是 Wasm 可见字节,Host state(Go globals、工具、WASI、执行栈等)不在 memfd image 中。mapping base 是一个实例的 Host 虚拟地址起点,不是 Guest 地址协议,也不要求不同实例相同。

1. Linux 文件只提供 Linux 实现

cow_linux.go · L1–L20 · 打开完整源码

//go:build linux

package spine

import (
	"errors"
	"fmt"
	"runtime"
	"sync"
	"unsafe"

	"github.com/tetratelabs/wazero/api"
	"github.com/tetratelabs/wazero/experimental"
	"golang.org/x/sys/unix"
)

const wasmPageSize = 65536

var cowPageSize = unix.Getpagesize()

构建标签使这份文件只在 Linux 参与编译;非 Linux 使用 cow-platform.md 的构造器。导入中的 unix 提供 memfd、mmap、seal、Pwrite、Munmap 等系统调用,unsafe 只在 attach 时观察已有 slice 的起点,runtime.KeepAlive 保证 unsafe 调用结束前该 slice 仍被视为存活。

wasmPageSize = 65536 是 Guest 内存的协议单位;cowPageSize 则从内核查询 OS page 大小。两者可能相同,但代码不能依赖这种巧合:Wasm 增长用前者,文件长度和 mapping 地址检查用后者。

2. 两个结构体分别持有什么

cow_linux.go · L21–L41 · 打开完整源码

type linuxCOW struct {
	fd        int
	imageSize uint64
	closed    bool
}

type linuxCOWMemory struct {
	mapping []byte
	max     int
	mu      sync.Mutex
}

func newCOWRuntime() (cowRuntime, error) {
	fd, err := unix.MemfdCreate("pysolate-spine-cow", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING)
	if err != nil {
		return nil, fmt.Errorf("create COW image memfd: %w", err)
	}
	return &linuxCOW{fd: fd}, nil
}

func (c *linuxCOW) allocator(def api.MemoryDefinition) (cowMemory, error) {

linuxCOW 是 Runner 级对象,持有 memfd、已提交的 imageSize 和 closed 状态。新建 runtime 只调用 MemfdCreate,得到一个允许加 seals 的空文件;此时 fd 存在,imageSize 仍为零,ready() 必须返回假。

linuxCOWMemory 是实例级对象。mapping 是对一段 Host 虚拟地址范围的 Go slice 描述,实际页由内核映射承载;max 是预留范围的字节上限;mu 保护增长与释放之间的状态。这个 slice 不是 Go 堆里的完整镜像,也不能在实例释放后继续当作有效 Guest memory 使用。

3. 初始化:按最大 Wasm 页数预留 mapping

cow_linux.go · L41–L63 · 打开完整源码

func (c *linuxCOW) allocator(def api.MemoryDefinition) (cowMemory, error) {
	if def == nil {
		return nil, errors.New("COW needs an exported memory named memory")
	}
	maximum, bounded := def.Max()
	if !bounded {
		return nil, errors.New("COW needs a declared memory maximum")
	}
	return newLinuxCOWMemory(uint64(maximum) * wasmPageSize)
}

func newLinuxCOWMemory(maximum uint64) (*linuxCOWMemory, error) {
	if maximum == 0 || maximum > uint64(maxInt()) {
		return nil, fmt.Errorf("invalid COW memory maximum: %d", maximum)
	}
	mapping, err := unix.Mmap(-1, 0, int(maximum), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANONYMOUS)
	if err != nil {
		return nil, fmt.Errorf("reserve COW linear memory (%d bytes): %w", maximum, err)
	}
	return &linuxCOWMemory{mapping: mapping, max: int(maximum)}, nil
}

// The one-memory Guest gets the range reserved before InstantiateModule.

allocator 先要求模块存在导出的 memory 定义,并要求该定义声明 maximum。COW 需要一个已知上限,才能在 InstantiateModule 之前为每个实例预留完整地址范围。代码把最大 Wasm 页数乘以 64 KiB 得到最大字节数,再检查它是否非零且能放进 Go int。

newLinuxCOWMemory 用 MAP_PRIVATE|MAP_ANONYMOUS 创建可读写匿名范围。这里还没有映射 memfd,也没有捕获 image;它只是给 wazero 一个实例专属的 mapping base 和最大容量。后续 Reallocate 会用这个范围的前缀表示 Guest 当前可见长度,尾部仍属于预留范围。

例如最大值为 8192 个 Wasm 页时,预留的是 512 MiB 的虚拟地址范围,不等于立即占有 512 MiB 独占物理内存。这个实现只建立地址和生命周期条件,不从预留大小推出性能结果。

错误集中在初始化阶段:memory 定义缺失、没有 declared maximum、最大值为零或超出 int、mmap 失败都会返回错误;它们不会创建一个没有明确上限的可用 COW memory。

4. 实例增长:固定起点,改变可见前缀

cow_linux.go · L63–L78 · 打开完整源码

// The one-memory Guest gets the range reserved before InstantiateModule.
func (m *linuxCOWMemory) Allocate(_, _ uint64) experimental.LinearMemory {
	return m
}

func (m *linuxCOWMemory) Reallocate(size uint64) []byte {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.mapping == nil {
		return nil
	}
	if size > uint64(m.max) || size > uint64(maxInt()) {
		return nil
	}
	return m.mapping[:int(size):m.max]
}

Allocate 直接返回已经预留的 linuxCOWMemory,因为范围在实例化前就准备好了。Reallocate 加锁后检查 mapping 是否已释放,再拒绝超过 max 或 int 上限的字节数,最后返回 m.mapping[:size:max]。三索引 slice 让当前长度变成 size,同时把容量限制在预留上限;它不移动起点,也不把尾部复制到别处。

这里的 size 是字节数。wazero 的 Wasm Memory.Grow 会按 Wasm page 计算并通过 allocator 调整它;新增的匿名尾部应当读为零。Linux OS page 只参与底层映射和私有化,不改变 Memory.Grow 的 Wasm page 语义。

5. 实例释放:Free 和 release 的职责不同

cow_linux.go · L80–L95 · 打开完整源码

func (m *linuxCOWMemory) Free() {
	// Wazero's allocator hook has no error return.
	_ = m.release()
}

func (m *linuxCOWMemory) release() error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.mapping != nil {
		if err := unix.Munmap(m.mapping); err != nil {
			return err
		}
		m.mapping = nil
	}
	return nil
}

wazero 的 Free 没有错误返回,因此它调用带错误的 release 后丢弃错误。release 自己加锁;如果 mapping 还存在,就调用 Munmap,成功后把 mapping 置为 nil。再次 release 或 Free 不会重复解除同一范围;释放后 Reallocate 返回 nil。

如果 Munmap 失败,release 保留原 mapping 并返回错误,避免把未完成的释放报告成成功。这个方法只处理实例级匿名范围和 attach 后的映射,不关闭 linuxCOW.fd。fd 属于 Runner,必须等所有实例结束后由 close 处理。

6. 基线捕获:Guest memory 写入 memfd

cow_linux.go · L97–L108 · 打开完整源码

func (c *linuxCOW) capture(memory api.Memory) error {
	if memory == nil || memory.Size() == 0 {
		return errors.New("cannot capture empty COW memory")
	}
	data, ok := memory.Read(0, memory.Size())
	if !ok {
		return errors.New("cannot read initialized Guest memory for COW image")
	}
	return c.captureBytes(data)
}

// captureBytes writes directly from the allocator-backed mapping into the

capture 要求输入的 api.Memory 非空且有字节,然后从偏移 0 读取当前 Guest 可见内容,交给 captureBytes。这段读取发生在参考 Guest 完成 _initialize 和 CPython init 之后,调用顺序由 prepared.go 保证。

memory.Read 返回借用 view;captureBytes 在写入 fd 时使用它,但不会把它作为 Runner 的长期 Go image 保存。COW 的基线所有者是封存后的内核文件对象,Runner 只通过 linuxCOW 持有 fd 和 image 大小。

7. 提交基线:对齐、写入、封存

cow_linux.go · L108–L141 · 打开完整源码

// captureBytes writes directly from the allocator-backed mapping into the
// sealed memfd. It deliberately does not retain a Go-owned image copy.
func (c *linuxCOW) captureBytes(data []byte) error {
	if len(data) == 0 || len(data)%cowPageSize != 0 || len(data)%wasmPageSize != 0 {
		return fmt.Errorf("COW image size must be Wasm-page aligned: %d", len(data))
	}
	if c.closed {
		return errors.New("COW image is closed")
	}
	if c.imageSize != 0 {
		return errors.New("COW image already captured")
	}
	if err := unix.Ftruncate(c.fd, int64(len(data))); err != nil {
		return fmt.Errorf("size COW image: %w", err)
	}
	for offset := 0; offset < len(data); {
		n, err := unix.Pwrite(c.fd, data[offset:], int64(offset))
		if err != nil {
			return fmt.Errorf("write COW image: %w", err)
		}
		if n == 0 {
			return errors.New("short write while creating COW image")
		}
		offset += n
	}
	seals := unix.F_SEAL_SHRINK | unix.F_SEAL_GROW | unix.F_SEAL_WRITE | unix.F_SEAL_SEAL
	if _, err := unix.FcntlInt(uintptr(c.fd), unix.F_ADD_SEALS, seals); err != nil {
		return fmt.Errorf("seal COW image: %w", err)
	}
	c.imageSize = uint64(len(data))
	return nil
}

func (c *linuxCOW) ready() bool {

captureBytes 先要求长度非零,并同时满足 OS page 和 Wasm page 对齐。前者是文件映射和地址操作的条件,后者保证 Guest 内存形状按 Wasm page 表示。它还拒绝已关闭或已经捕获过的 runtime。

随后代码把 memfd 截断到 image 长度,用带明确 offset 的 Pwrite 循环写完所有字节;部分写入继续,写错误或零字节写立即结束。写完后一次性加入 F_SEAL_SHRINK、F_SEAL_GROW、F_SEAL_WRITE 和 F_SEAL_SEAL。只有加 seals 成功,才把 imageSize 写入对象,因此 ready() 不会把半成品当成基线。

这一段的状态提交点是 imageSize = len(data)。在它之前,fd 可能已截断或写入但仍不算可 attach 的 image;在它之后,capture 被视为完成且不能重复。失败按“长度/状态检查、文件大小、写入、加 seal”分组返回,调用者负责关闭构造阶段的 Guest 和 Runner。

8. attach:初始化之后在原 mapping base 替换前缀

cow_linux.go · L141–L160 · 打开完整源码

func (c *linuxCOW) ready() bool {
	return !c.closed && c.imageSize != 0
}

func (c *linuxCOW) attach(memory api.Memory) error {
	if memory == nil {
		return errors.New("cannot attach COW image to nil memory")
	}
	if uint64(memory.Size()) < c.imageSize {
		pages := uint32((c.imageSize - uint64(memory.Size())) / wasmPageSize)
		if _, ok := memory.Grow(pages); !ok {
			return errors.New("cannot grow memory to COW image")
		}
	}
	data, ok := memory.Read(0, memory.Size())
	if !ok {
		return errors.New("cannot inspect Guest memory for COW attach")
	}
	return c.attachBytes(data)
}

ready 只有在 runtime 未关闭且 imageSize 非零时为真。attach 先检查目标 Guest,然后在其当前大小小于 image 时按 Wasm page 增长;它不主动缩小已经存在的实例。接着读取当前 Guest memory,目的不是复制这些字节,而是取得 wazero 当前持有的 mapping base,并把这个地址交给 attachBytes。

因此 attach 必须发生在 _initialize 之后:初始化先在实例的匿名范围中完成,attach 再替换 image 覆盖的前缀。匿名尾部保持本实例的映射,之后仍可按 Wasm page 增长。

9. MAP_PRIVATE|MAP_FIXED 提供写隔离

cow_linux.go · L162–L185 · 打开完整源码

// attachBytes replaces exactly the current mapping range. MAP_PRIVATE keeps
// the sealed file pages shared until a Guest write faults a page private.
func (c *linuxCOW) attachBytes(data []byte) error {
	if c.closed {
		return errors.New("COW image is closed")
	}
	if c.imageSize == 0 || uint64(len(data)) != c.imageSize {
		return fmt.Errorf("COW memory shape changed: got %d, want %d", len(data), c.imageSize)
	}
	if len(data) == 0 || uintptr(unsafe.Pointer(unsafe.SliceData(data)))%uintptr(cowPageSize) != 0 {
		return errors.New("COW memory address is not page-aligned")
	}
	address := unsafe.Pointer(unsafe.SliceData(data))
	mapped, err := unix.MmapPtr(c.fd, 0, address, uintptr(len(data)), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_FIXED)
	if err != nil {
		return fmt.Errorf("map sealed COW image at existing address: %w", err)
	}
	if mapped != address {
		_ = unix.MunmapPtr(mapped, uintptr(len(data)))
		return errors.New("COW mapping address changed")
	}
	runtime.KeepAlive(data)
	return nil
}

attachBytes 拒绝 closed、未 capture、长度不等于 image、空 slice 和未按 OS page 对齐的地址。它用 unsafe.SliceData 观察当前 slice 的首地址,然后调用 MmapPtr:文件偏移从 0 开始,长度正好是 image,权限可读写,标志是 MAP_PRIVATE|MAP_FIXED。

MAP_FIXED 把 sealed memfd 前缀放回这个实例原来的 mapping base;MAP_PRIVATE 才提供写入时的私有化语义。内核在写者首次修改某个 OS page 时建立该页的私有副本,其他实例仍可读封存页;这不是按 64 KiB Wasm page 整块复制。MAP_FIXED 返回的地址必须与请求地址完全相同,代码拒绝任何“接近”的结果,并尝试解除错误映射。

runtime.KeepAlive(data) 只保护这次 unsafe 调用期间 slice 的 Go 引用存活。它不拥有内核 mapping,也不能替代 Munmap。attach 成功后,前缀由 memfd 私有映射承载,未覆盖的匿名尾部仍在同一实例范围内。

状态可以写成:

预留:       [匿名前缀 | 匿名尾部]
_initialize: [实例初始写入 | 匿名尾部]
attach:      [sealed memfd 私有映射 | 匿名尾部]
A 写 OS page: [A 的私有页 | 匿名尾部]
B 未写:      [封存基线页 | 匿名尾部]

A、B 的 Host mapping base 可以不同;每个实例只需满足自身 attach 前后地址不变。

10. Runner 释放:先实例,后 fd

cow_linux.go · L187–L201 · 打开完整源码

func (c *linuxCOW) close() error {
	if c.closed {
		return nil
	}
	c.closed = true
	if err := unix.Close(c.fd); err != nil {
		return fmt.Errorf("close COW image: %w", err)
	}
	c.fd = -1
	return nil
}

func maxInt() int {
	return int(^uint(0) >> 1)
}

close 是幂等的。它先把 closed 设为真,再关闭 fd;失败时返回带上下文的错误,成功后把 fd 设为 -1。关闭 fd 释放 Runner 对内核文件对象的引用,但不能代替实例映射的 Free/release;已有映射的生命周期仍由实例持有。

maxInt 根据当前架构计算 Go int 上限,供最大内存和 slice 长度检查使用。它不是 Wasm 的上限;Wasm maximum 来自 module definition,OS page size 只用于底层映射条件。

11. 一条完整状态链

以 image 为 2 个 Wasm 页、实例最大值为 4 个 Wasm 页为例:

  1. Runner 创建空 memfd,ready=false。
  2. 参考实例预留 4 页,完成 _initialize/init,捕获前 2 页并加 seals,ready=true。
  3. 新实例预留自己的 4 页,完成 _initialize,attach 前 2 页;后 2 页仍是匿名尾部。
  4. Memory.Grow(1) 只扩大 Guest 可见长度,mapping base 不变,新尾部为零。
  5. Guest 写入前缀的一个 OS page,只有该实例获得私有页;memfd 和另一个实例不变。
  6. 实例关闭时解除自己的范围;所有 Run 结束后 Runner 关闭 fd。

这条链解释的是本仓库的 prepared COW 实现,不是通用 snapshot/restore 格式。内核层断言见 cow-kernel-tests.md,真实 Guest 行为见 cow-guest-tests.md。