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

wazero Host 状态与 Guest 内存

模块、实例、引擎和线性内存分别保存什么。

本章目录

当代码执行 m.Memory().Read(offset, length) 时,读到的是某个 Wasm 实例的线性内存。围绕这段内存,wazero 还维护编译结果、函数引用、全局值、table、系统调用上下文和执行栈。把这些对象分别画出来,才能判断一个内存快照究竟保留了多少状态。

本文读取 wazero v1.11.0 的实际源码,并对照 Spine 190ea2e 的调用方式。公共对象关系适用于本文讨论的模块;编译执行细节沿 wazevo 引擎展开。解释器使用另一套执行结构,不能直接套用编译器的 VMContext 字段。

1. 先看三层对象关系

Host 对象、Guest 线性内存与内核页映射

这张图表示对象和映射关系。框的高度不代表内存占用,框中列出的内容也没有按字节地址排列。

最上面是 Go 进程中的对象。中间是每个 Guest 的线性地址空间。在 Linux COW 模式下,最下面还需要追到内核的文件页与映射关系。

同一个 Go 类型 []byte 可以出现在不同层:它既可以描述普通 Go 数组,也可以描述外部 mmap 得到的区域,还可以作为编译器内部的状态缓冲。阅读时应继续追它的分配者和使用者。

2. Runtime、CompiledModule、Module 分别负责什么

Spine 的入口有三个连续动作:

NewRuntimeWithConfig
    └─ 建立 wazero runtime、Store 和执行引擎
CompileModule(wasm bytes)
    └─ 解码、验证并编译模块
InstantiateModule(compiled module, config)
    └─ 建立一次具体实例

Runtime 的 Store 管理模块实例、类型身份等运行时关系。CompiledModule 持有已解码的 Wasm 模块和相应引擎中的编译结果。它可以用于多次实例化。

api.Module 表示其中一次实例化的结果。Spine 每轮运行都新建这一层,所以可以复用编译代码,同时让各轮有自己的 Guest 状态。

本地入口:

runner.go · L16–L31 · 打开完整源码

)

// Runner owns compiled code, tools and an optional clean image, never a live user Guest.
// Close must happen after all Run calls return. Tool implementations must honor context.
type Runner struct {
	runtime       wazero.Runtime
	code          wazero.CompiledModule
	manifest      Manifest
	guestManifest []guestToolSpec
	image         []byte // Immutable full-copy baseline; COW owns its image separately.
	cow           cowRuntime
}

type guestToolSpec struct {
	Name           string `json:"name"`
	AllowEarlyRead bool   `json:"allow_early_read"`

runner.go · L33–L57 · 打开完整源码


type Output struct {
	Value       json.RawMessage
	Stdout      string
	Transformed string // Optional actual Guest AST rendering for learning, not execution input.
}

var pythonIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)

var pythonKeywords = map[string]bool{
	"False": true, "None": true, "True": true, "and": true, "as": true, "assert": true,
	"async": true, "await": true, "break": true, "class": true, "continue": true, "def": true,
	"del": true, "elif": true, "else": true, "except": true, "finally": true, "for": true,
	"from": true, "global": true, "if": true, "import": true, "in": true, "is": true,
	"lambda": true, "nonlocal": true, "not": true, "or": true, "pass": true, "raise": true,
	"return": true, "try": true, "while": true, "with": true, "yield": true,
}

// New compiles the actual artifact once; every Run instantiates private memory.
func New(ctx context.Context, wasm []byte, manifest Manifest) (*Runner, error) {
	r := &Runner{
		runtime:       wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCloseOnContextDone(true)),
		manifest:      make(Manifest, len(manifest)),
		guestManifest: make([]guestToolSpec, 0, len(manifest)),
	}

上游定位:runtime.go 的编译过程、compiledModule 的字段与 Close。

可复用代码和每轮数据怎样分开

编译器需要知道指令、类型、导入导出和模块定义,随后生成可执行代码。实例则提供执行这些代码时使用的 memory、globals、table 和导入对象。

可以把一个函数调用理解为:找到可执行代码,再提供当前实例的上下文。同一份代码服务于实例 A、B 时,会通过各自的实例上下文取到各自的内存与全局值。

3. ModuleInstance 是一组对象关系

编译结果还占用哪些 Host 资源

wazevo 的 engine 保存以模块身份为键的编译缓存、按可执行地址排序的模块列表,以及 memory.grow、stack grow 等共享 trampoline。内部 compiledModule 还记录函数入口偏移、模块上下文布局和调试所用的地址映射。

机器码由 platform.MmapCodeSegment 分配的可执行区域承载,再用 slice 和指针描述。它与普通 Go 堆上的管理对象、Guest Buffer 是不同的分配。分析总占用时,需要分别考虑代码区、编译元数据、每实例状态和线性内存,不能用 memory.Size() 代表整个 runtime。

定位:engine 和 compiledModule 字段、机器码内存分配。

每次实例化建立的对象

wazero 的 internal/wasm/store.go 中,ModuleInstance 实现了 api.Module。下面按用途归类其主要字段:

字段 保存的内容 与线性内存的关系
Exports 导出名到导出定义的映射 Go 侧的查找元数据
Globals GlobalInstance 引用 Wasm globals 的表示与访问入口
MemoryInstance 当前 memory 对象 其中的 Buffer 描述 Guest 字节区间
Tables table 实例 存函数等引用,供间接调用使用
Engine 当前实例的执行引擎对象 连接编译代码与实例状态
TypeIDs Store 分配的函数类型身份 用于间接调用等检查
DataInstances data segment 的运行时表示 供 bulk memory 操作使用
ElementInstances element segment 的运行时表示 与 table 初始化和引用有关
Sys WASI 等使用的系统上下文 文件等 Host 资源状态
Closed 关闭状态与退出码 原子更新的 Go 侧状态
Source 产生实例的 Wasm Module 定义 指回已解码的模块

这些字段并非全部深拷贝得到。源模块定义和编译结果可以共享;导入对象也可以连接到已有实例。Spine 的隔离设计依赖其实际 artifact 和实例化配置,不能仅凭“调用了 Instantiate”就推断所有外部对象都已独立。

上游定位:ModuleInstance / GlobalInstance。

data segment 与线性内存中的数据有两种角色

Wasm 文件中的 data segment 是初始化输入。active segment 在实例化时把内容写入指定 memory 区间;passive segment 可以留给后续 memory.init 使用。

因此会同时看到:已编译模块中保存的段内容、实例用于管理段状态的字段,以及 memory 中已经写入的字节。仅从名字里都有 Data,无法判断它们是否指向同一份存储。

4. MemoryInstance.Buffer 是怎样分配的

MemoryInstance 的核心字段包括:

Buffer        []byte
Min, Cap, Max uint32
Shared        bool

Min、Cap、Max 使用 Wasm 页单位,len(Buffer) 使用字节单位。一页为 65536 字节。

构造函数会按配置选择分配路径:

  1. 有实验性 allocator 时,调用 Allocate 和 Reallocate,由 allocator 提供存储。
  2. 普通路径通过 Go 的 make 创建 buffer。
  3. WebAssembly shared memory 有额外的固定容量和并发要求。

Spine 的 full-copy 路径使用新实例自己的 buffer,再把基线写进去。Linux COW 路径通过 experimental.WithMemoryAllocator 提供自行管理的映射。

上游定位:MemoryInstance 与 NewMemoryInstance。

Shared 字段与 COW 中的“共享页”含义不同

MemoryInstance.Shared 对应 Wasm 的共享内存声明,涉及 threads/atomic 等语义。当前 Spine artifact 的 memory 声明没有设置 shared 标志。

Linux COW 的多个私有映射仍然可以引用同一个未修改的文件页。Guest A 写入后,A 的映射得到自己的私有页,B 保持原内容。Wasm 层看到各实例私有的地址空间,内核层则在写入之前复用物理页。

5. Read 得到的是可写视图

Memory.Read 会检查范围,随后返回指向相应 buffer 区间的 slice。修改这个 slice 会修改 Guest 看到的字节。

源码使用三下标切片:m.Buffer[offset : offset+byteCount : offset+byteCount]。返回 view 的长度和容量都限定在这次请求的区间;调用方对这个 view 做 append 时,超过该容量会分配别的数组。这个容量限制并未复制区间内的字节,已有元素仍与 Guest 共用存储。这把前面 Go slice 的知识具体用到了 ABI 读取上。

所以这段代码创建了两个描述符,底层字节仍然共用:

view, ok := memory.Read(offset, length)
if !ok { /* 处理越界 */ }
alias := view
alias[0] = 7

若需要一份独立结果,可以把字节复制到另一个数组中。Spine 使用 json.Unmarshal 把响应字段解码到 Go 拥有的结果对象,再释放 Guest 返回缓冲。

普通 allocator 的增长可能更换底层数组。旧 view 仍然指向旧数组;它既不会自动扩大长度,也不能继续被当作当前 memory 的完整视图。外部 allocator 还有关闭后失效的问题:Go 变量仍存在,不代表它描述的映射仍可访问。

上游公共约定:api.Memory 的 Grow / Read 说明。

6. wazevo 的 VMContext 里还有一份快速访问布局

编译出的机器码需要快速找到实例相关对象。moduleEngine 为此维护 moduleContextOpaque,可以把它理解为编译器和运行时共同约定的一块状态区域。

其字段布局由模块编译时计算,典型内容包括:

moduleContextOpaque
├─ ModuleInstance 的 Host 指针
├─ 本地 memory 的 Host base 与当前长度
├─ 导入 memory 的对象及所属上下文
├─ 导入函数引用
├─ 导入 globals 的引用
├─ 本地 globals 的值
├─ TypeIDs 和 table 对象入口
├─ data / element instances 入口
└─ 可选的监听器 trampoline

这块区域在 Go Host 中。里面可以包含 Go 对象地址和本机可执行代码指针。Guest 的普通 Wasm load/store 使用的是线性内存偏移,不会自动获得访问这块区域的能力。

上游源码把这些字段以注释结构列在 module_engine.go,setupOpaque 再把实际地址和值填入。

调试 globals 时,要找到实际存值的位置

GlobalInstance 中有 Val 和 ValHi,但源码也明确标了另一种情况:Me 非空时,当前值保存在 module engine 中,需要经 engine 访问。直接观察 Go struct 中的初始字段,可能看不到正在运行的最新值。

这正是快照容易遗漏的状态:复制 Guest Buffer 不会顺便复制这些 engine 内的 global 值。

7. 一次 load 如何找到真正的字节

假设 Guest 指令要访问偏移 o:

Guest 指令中的 offset o
    │
    ├─ 根据当前 memory 长度检查访问范围
    │
    └─ 利用当前实例的 Host memory base
           └─ 访问 base + o 对应的字节

范围检查还需要考虑指令自身的常量偏移与访问宽度,例如一次 i64 load 要覆盖完整的八个字节。

C 编译成 wasm32 后,许多指针值也以线性内存中的偏移表示。CPython 的对象指针、内部结构和 C 分配器数据可以存在 Guest 字节区间内。Host 则通过自己的 base 把这些偏移映射到实际地址。

实例 A 的 offset 1000 与实例 B 的 offset 1000 可以对应不同的 Host 地址。相同 artifact 的内存布局让这些内部偏移在各实例中具有相同的结构意义,Host base 无需相同。

8. 函数指针与 table 也需要单独看

Wasm 的间接调用通过 table 取出函数引用。C 编译得到的函数指针常常需要通过这套机制表达。

在 wazevo 中,functionInstance 保存可执行代码入口、所属模块上下文、类型身份和模块内下标。table 中的运行时引用连接到这些对象。调用时既要找到代码,也要使用正确实例的上下文。

因此有三样东西需要区分:

  • 线性内存里保存的函数指针表示;
  • table 中保存的函数引用;
  • Host 侧真正执行的代码和实例上下文。

仅复制第一项不能构成任意程序的完整执行快照。Spine 的 prepared 路径复用同一个已编译 artifact,并重新实例化 table 等状态,再恢复已知的初始化内存。

9. 至少有三种“栈”出现在一次 Python 执行中

Go goroutine 的栈

承载 Go 的调用过程,例如 Runner.Run、Host 工具和桥接代码的 Go 局部状态。

wazevo 执行栈

callEngine 有自己的 stack []byte、栈顶指针和 executionContext。其中涉及返回地址、保存的寄存器、进入 Go 回调的位置、grow trampoline 等。它用于执行编译后的 Wasm 函数。

这一层不在 MemoryInstance.Buffer 中。即使它也使用 []byte,也不能由 Guest 线性内存快照顺便保存。

Guest 的 C 栈与 Python 执行对象

C 中需要取地址的局部对象等可以使用线性内存里的 C 栈区域。CPython 还会在 Guest 内维护解释器对象、线程状态、Python frame 等结构。Python frame 的内存表示由 CPython 实现决定,应继续追 CPython 对象,而非一概当成 Go 的调用栈。

上游定位:callEngine / executionContext。

这也解释了为什么“在任意执行点拷贝全部线性内存,然后继续”需要更多机制:程序计数器、寄存器、引擎执行栈和可能悬挂的 Host 调用都还在别处。

10. 当前 artifact 的实际声明

可在仓库根目录运行下面的只读检查脚本,重新读取当前 artifact 的声明和段统计:

python3 learning/deep-probes/inspect_memory.py dist/spine.wasm

脚本覆盖本章 artifact 使用的段形式,遇到超出支持范围的形式会报错;输出包含被检查文件的 SHA-256,方便区分不同产物。

本次直接读取现有 dist/spine.wasm 的二进制段,完整结果见检查记录,摘要如下:

项目 观察值
memory 1 个,最小 2048 页,最大 8192 页
换算 初始 128 MiB,最大 512 MiB
table 1 个 funcref table,最小和最大均为 5955 项
globals 2 个 i32:一个可变、初值 16777216;一个不可变、初值 0
imports 43 项,全部为函数;来自 spine 与 wasi_snapshot_preview1
Spine imports call、prepare、resolve
data segments 10002 段,其中 10000 个 active 段、两个空 passive 段
active payload 总字节数 16885739

这份 artifact 没有导入 memory、table 或 global。每轮实例的这些本地状态由实例化过程建立,Host 导入函数则仍然引用已注册的运行时能力。

数据段数量并不等于进程占用

active payload 的总字节数只计算文件中明确写出的初始化内容。memory 的未覆盖区域也存在,初始零值、运行时分配和后续写入都会影响实际占用。

同样,128 MiB 和 512 MiB 来自 memory 声明,不是 RSS 测量。

为什么没有给每个 Python 对象画一个固定地址

最终文件经历过编译、链接和 wasi-vfs pack。当前二进制中的 active data 段从偏移 16775364 开始,最高结束位置为 134217677,分布并非几个简单连续色块。

上面的两个 global 初值是直接观察值。仅凭一个值等于 16 MiB,尚不足以给它下完整的符号身份结论;要进一步确认某个 global、静态符号或分配器区域,应结合符号表、链接 map 或相应指令使用点。

这篇先保留已确认的声明与对象关系,不把常见链接器布局当成这份最终文件的逐字节地址图。

11. memory.grow 的更新过程

wazero 的 MemoryInstance.Grow 大致按下面的顺序处理:

  1. 计算当前页数和请求后的页数。
  2. 超过声明最大值时直接失败。
  3. 使用实验性 allocator 时,请它提供新长度的 view。
  4. 普通存储在需要时扩容,否则调整 slice 长度。
  5. 调用所属 module engine 的 MemoryGrown(),更新引擎持有的相关状态。

上游定位:Grow。

普通路径有可能换一块底层数组;COW allocator 则在本实例预先持有的最大映射范围内返回更长的 view。

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

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.
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]
}

这里的匿名映射最初就使用 PROT_READ|PROT_WRITE。增长改变的是 Wasm 当前可访问长度,源码没有在每次 grow 时靠 mprotect 逐页开放权限。

12. 为什么 COW attach 要留在当前实例的地址上

实例已经完成建立,并执行过 _initialize。它的 MemoryInstance.Buffer 和编译器上下文已经知道内存的位置。随后恢复 COW 基线时,Spine 使用 MAP_FIXED 在它自己持有的范围上替换映射。

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

// 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

地址保持不变,原有 Host 视图和编译器保存的 base 仍然指向这一地址;这个地址背后的页来源改成 sealed memfd。

若随意在另一个地址建立映射,只修改某个 Go 局部 slice,其他已经缓存的 base 未必同步改变。当前实现选择固定地址替换,避免引入额外的重绑定路径。

这项约束发生在一个实例内部。A 和 B 各自拥有一段范围,只需在各自的范围上完成 attach。

13. 关闭一轮实例时,哪些东西会结束

ModuleInstance.Close 会更新关闭状态,从 Store 移除实例,再关闭它持有的资源。v1.11.0 的实现包含 Sys 文件系统上下文、实验性 memory allocator,以及按需存在的 CodeCloser。

上游定位:ModuleInstance 的关闭路径。

Spine 先显式编译一次,再用编译结果创建多轮实例。关闭某一轮 Guest 与关闭整个 Runner 是两个层次:

  • 本轮 Guest 关闭后,其 memory mapping 和实例相关资源不能继续使用。
  • Runner 仍持有可复用的编译结果、工具注册和基线。
  • Runner 的关闭约定要求先等所有 Run 返回,再关闭 runtime 和 COW 基线资源。

不能把 CompiledModule.Close 理解成直接把所有正在使用它的实例一起销毁。上游公共接口允许仍有实例调用时关闭 compiled handle,具体代码释放还涉及引擎引用关系。Spine 用更直接的 Runner 生命周期约定管理这整组对象。

14. 用一张清单判断某个值会不会被 COW 保留

一个具体例子:stdout 的两端

prepared.go:newGuest 为每个实例调用 WithStdout(stdout) 和 WithStderr(stderr);这些 writer 来自本轮新建的 Go boundedText。Python 侧的 sys.stdout 对象则属于 Guest 内存,通过 WASI 的标准输出约定把字节送出来。

因此要同时看两端:Guest 恢复自己的对象,Host 为本轮绑定输出目标。这能解释为何恢复初始化内存之后,输出仍写到本轮的 buffer。

若扩展运行环境后,预热过程通过 Host 打开了额外文件,Guest 内存里保存的 fd 数字还需要在新实例的 Host 文件表中找到正确对象。复制这个数字并不会重建那个文件。检查快照资源时,沿“Guest 中的引用 → Host 中对应对象”继续追踪,才能判断恢复后是否仍有效。

看到一个字段或指针,可以按下面顺序追:

  1. 它由谁分配:Go、Guest 的 malloc、CPython,还是 mmap?
  2. 它是描述符、引用,还是实际字节?
  3. 它指向线性内存内部,还是 Host 对象、代码、文件状态?
  4. 新实例能否从同一个模块定义重建它?
  5. 当前是否还有调用栈或后台操作正在使用它?

例如 f.response []byte 在 Go Future 中,r.image []byte 是一份 Go 拥有的基线副本,MemoryInstance.Buffer []byte 描述 Guest 内存,callEngine.stack []byte 是执行引擎的栈。它们的类型相同,生命周期和可复制条件却要分别分析。

继续读 COW 与安全复用篇,可以把这张对象清单对应到具体的捕获、映射和关闭步骤。