当代码执行 m.Memory().Read(offset, length) 时,读到的是某个 Wasm 实例的线性内存。围绕这段内存,wazero 还维护编译结果、函数引用、全局值、table、系统调用上下文和执行栈。把这些对象分别画出来,才能判断一个内存快照究竟保留了多少状态。
本文读取 wazero v1.11.0 的实际源码,并对照 Spine 190ea2e 的调用方式。公共对象关系适用于本文讨论的模块;编译执行细节沿 wazevo 引擎展开。解释器使用另一套执行结构,不能直接套用编译器的 VMContext 字段。
1. 先看三层对象关系
这张图表示对象和映射关系。框的高度不代表内存占用,框中列出的内容也没有按字节地址排列。
最上面是 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 字节。
构造函数会按配置选择分配路径:
- 有实验性 allocator 时,调用
Allocate和Reallocate,由 allocator 提供存储。 - 普通路径通过 Go 的
make创建 buffer。 - 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 大致按下面的顺序处理:
- 计算当前页数和请求后的页数。
- 超过声明最大值时直接失败。
- 使用实验性 allocator 时,请它提供新长度的 view。
- 普通存储在需要时扩容,否则调整 slice 长度。
- 调用所属 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 中对应对象”继续追踪,才能判断恢复后是否仍有效。
看到一个字段或指针,可以按下面顺序追:
- 它由谁分配:Go、Guest 的 malloc、CPython,还是 mmap?
- 它是描述符、引用,还是实际字节?
- 它指向线性内存内部,还是 Host 对象、代码、文件状态?
- 新实例能否从同一个模块定义重建它?
- 当前是否还有调用栈或后台操作正在使用它?
例如 f.response []byte 在 Go Future 中,r.image []byte 是一份 Go 拥有的基线副本,MemoryInstance.Buffer []byte 描述 Guest 内存,callEngine.stack []byte 是执行引擎的栈。它们的类型相同,生命周期和可复制条件却要分别分析。
继续读 COW 与安全复用篇,可以把这张对象清单对应到具体的捕获、映射和关闭步骤。