这章只回答一个问题:一段 Python 源码,怎样经过构建和运行,最后在 Pysolate Spine 的 Guest 里产生结果?读者只需要知道变量、条件、循环和函数。这里会出现 C、编译器、Wasm 和操作系统术语,但每个术语都放回当前仓库的真实路径中解释。
先记住一条主线:构建阶段把 C bridge、CPython 和标准库做成 dist/spine.wasm;运行阶段由 Go 的 wazero 加载它,再由 Wasm 内的 CPython 编译并执行用户 Python。 用户 Python 不是直接被编译成 Wasm。
相关源码:build-guest.sh、guest/runtime.c、guest/bootstrap.py、runner.go、prepared.go。并发部分见 concurrency.md;Go 语法和数据模型见 go-language.md 与 go-values.md。
先给两个名字一个位置:CPython 是 Python 语言的一种具体实现,主体用 C 编写;它负责理解 Python 源码、执行指令和管理 Python 对象。C bridge 是本项目写的一小段连接代码,把 Host/Wasm 调用接到 CPython,并不是另一套 Python 解释器。跨边界的入口和参数表示规则称为 ABI,下一章会展开。
1. 源码、可执行文件和机器指令
源码是给人读、给工具翻译的文本。例如下面是用户传给 Runner 的 Python 源码:
price = tool("lookup", key=inputs["item"])
result = price * inputs["quantity"]
这段文字本身不会自动运行。某个执行者必须先理解它:要么把它翻译成更低层的形式,要么逐步解释它。
可执行文件或可执行 artifact 是已经按某种格式准备好、可以交给运行时加载的文件。dist/spine.wasm 就是本项目的可执行 artifact。它不是 macOS 或 Linux 能直接双击启动的原生程序,而是一个 Wasm 模块:里面有代码、数据、导入和导出。wazero 负责理解这种格式并运行它。
机器指令是当前 CPU 能直接执行的低层指令,例如加法、跳转和读写寄存器。寄存器可以先理解为 CPU 内部很小、很快的临时存储格;跳转则是改变“下一条要执行哪条指令”,条件和循环会用到它。C 编译器通常会把 C 源码变成目标代码;Wasm 也有自己的指令格式。Pysolate 的构建结果首先是 Wasm 指令,不是直接交给宿主操作系统运行的原生可执行文件。wazero 加载模块后,可以用解释执行或编译执行策略运行 Wasm;如果使用编译执行策略,runtime 会把 Wasm 指令准备成宿主 CPU 能执行的形式。这个细节不改变边界:用户 Python 仍然由 Guest 内的 CPython 处理。
可以把三者先这样区分:
- 源码:人写的规则,例如
result = price * quantity。 - 可执行 artifact:已经打包好的模块,例如
spine.wasm。 - 机器指令:某个 CPU 最终执行的低层步骤。
这不是三份“同样的代码”。每一层都有自己的格式和执行者。
2. 编译器和解释器可以同时存在
“Python 是解释执行的”不等于“整个系统没有编译”。本项目同时有多次翻译和执行。
2.1 构建时:编译 C 并链接 CPython
build-guest.sh 用 wasi-sdk 的 clang 编译 guest/runtime.c,然后把它和已经编好的 CPython 静态库、其他支持库链接起来。命令中的核心片段如下:
# 片段,非完整构建脚本
"$SDK/bin/clang" --target=wasm32-wasip1 ... \
"$ROOT/guest/runtime.c" "$WASI/libpython3.14.a" ... \
-o "$WORK/raw.wasm"
编译器读取 C 源码,检查语法和类型,并产生 Wasm 目标代码。libpython3.14.a 这种以 .a 结尾的文件是静态库:它不是一段等待解释的 Python 源码,而是许多已经编译好的目标代码文件组成的归档。链接器从这些归档中取出需要的部分,把 C bridge、CPython 和依赖组合成一个模块。这里复用了已有的 CPython 静态库,不是每次构建都重新编译整个 CPython 源码。
脚本还链接 CPython 所需的支持库:libmpdec.a 负责十进制数计算,HACL 提供密码学基础实现,expat 解析 XML 这类带标签的结构化文本,libwasi_vfs.a 提供嵌入文件系统。这里先认识它们在构建中的职责,不要求你实现这些第三方库内部的数学或解析算法。脚本还指定 -mexec-model=reactor、导出内存、初始/最大内存和 C stack 大小。最后,脚本把 Python 标准库复制到临时目录,再用 wasi-vfs 将它放进 Wasm,输出 dist/spine.wasm。wasm-tools validate 只检查 Wasm 格式是否有效,不等于已经证明工具调用和 Python 语义都正确。
2.2 读 C bridge 时,先认这些外形
C 也是一门编程语言,但更直接地暴露内存和数据表示。你不用先会整门 C;读当前文件时先认这些记号:
#include <Python.h>引入头文件里的类型/函数声明,告诉编译器接口长什么样。它不是“运行 Python”,也不代替链接库的实现。#define定义编译前展开的宏。例如EXPORT("init")展开成编译器识别的导出标记,不是每次调用才执行的 Python 函数。{ ... }表示语句块或初始化一组字段,;结束一条语句,return交回结果。- 类型后面的
*表示指针,例如PyObject *定位一个 Python 对象;不是这里要做乘法。下一章解释地址和寿命。 NULL表示空指针。这里返回 NULL 往往同时表示已设置 Python 异常,不是成功返回字符串"NULL"。static函数只在这份 C 源文件内使用;文件级 static 变量保留状态。在这里它属于每个 Guest,不是所有 Run 共用一个 Host 全局值。config.site_import是取结构体字段;形如p->field则是通过指针取它所指对象的字段。condition ? a : b是条件表达式:条件成立取 a,否则取 b。status.err_msg ? status.err_msg : "unknown error"就是在没有错误消息时选一段备用文字。
methods 这类表把 Python 方法名对应到 C 函数;末尾的空项标记表结束。你先理解它在连接两层,不必背 CPython 宏展开后的全部字段。__attribute__(...) 是这里使用的编译器扩展标记,告诉工具链导入/导出的模块和函数名。
2.3 运行时:wazero 处理 Wasm,CPython 处理 Python
Go 的 runner.go 在 New 中创建 wazero runtime,安装 WASI,注册名为 spine 的 Host 模块,再调用 CompileModule。这里的 CompileModule 处理的是 Wasm 模块,不是用户这次提交的 Python 源码。
随后每次 Run 都创建一个新的 Wasm 实例。实例里面已经有 CPython 和 _spine 扩展。执行到 guest/bootstrap.py 的 execute 时,才处理用户源码:
# 片段,非完整函数;见 guest/bootstrap.py 的 execute
code = request["source"]
exec(compile(code, "<spine>", "exec"), scope)
这里的 compile 是 CPython 的运行时函数。它把 Python 文本编译成 CPython 的 code object。code object 是解释器更容易执行的内部表示,里面有指令、常量和名称等信息;它不是 Wasm,也通常不是宿主 CPU 的机器码。exec 再让 CPython 在 scope 这个名字空间中执行 code object。
所以有两次容易混淆的“编译”:
- 构建时,
clang把 C bridge 和库链接成 Wasm artifact;运行时由 wazero 处理这个 Wasm。 - 每次执行时,CPython 把用户 Python 源码变成 code object,再由 CPython 执行。
这两次编译的输入、输出和执行者都不同。把用户 Python 说成“被编译成 Wasm”会把它们混为一谈。
3. 一次普通 Run 的真实顺序
从外到内跟一遍,比记名词更有用。
第一步:Go 准备模块和 Host
New 保存编译后的 wazero.CompiledModule,复制工具注册表,实例化 wasi_snapshot_preview1,并注册 spine.call、spine.prepare 和 spine.resolve。它还检查 Guest 是否导出 _initialize、init、alloc、release 和 execute。缺少这些导出名时,Runner 会报错。这只是当前自建 Guest 的入口检查,不是为任意未知 Wasm 做完整 ABI 认证。
这里的“编译代码可复用”只表示同一份 Wasm 编译结果可以用于多个实例,不表示多个 Python Run 共享同一个 Python 堆或模块全局变量。
第二步:创建本轮的 Wasm 实例
runner.go 的 run 为本轮建立状态和 stdout/stderr 缓冲区,然后调用 prepared.go 的 newGuest。普通模式下,核心片段是:
// 片段,非完整函数;见 prepared.go 的 Runner.newGuest
m, err := r.runtime.InstantiateModule(ctx, r.code, ...)
...
if _, err = m.ExportedFunction("_initialize").Call(ctx); err != nil { ... }
status, err := m.ExportedFunction("init").Call(ctx)
InstantiateModule 创建的是一个实例:它有自己的线性内存、Wasm 状态和 Guest 资源。_initialize 是工具链和模块初始化所需的入口;init 是 guest/runtime.c 导出的入口,负责启动 CPython 并导入 spine_bootstrap。它们都先于用户 Python。
第三步:C bridge 把调用交给 CPython
C 的 execute 会调用初始化时缓存的 Python 函数对象 execute_fn。bootstrap.execute 解析请求 JSON,建立包含 __name__、inputs 和 tool 的 scope,然后执行 compile/exec。Python 程序中普通的变量、条件、循环和函数都在这里由 CPython 负责。
如果用户调用 tool,bootstrap.py 把工具名和参数编码成 JSON,调用 _spine.call。runtime.c 中的 python_call 再调用导入的 spine.call,实际实现位于 Go 的 hostCall。工具结果沿相反方向回到 Python;具体的指针、长度和 buffer 所有权见 memory-abi.md。
第四步:返回结果并关闭实例
bootstrap.execute 读取 scope.get("result"),将成功值或错误编码成 JSON bytes。C execute 为这份结果申请独立的 malloc buffer,复制 bytes 后把“长度和指针”打包进一个 uint64。Go 拆出指针和长度,检查线性内存范围,读取 JSON,随后调用 Guest 的 release 并关闭本轮实例。
Go 的 Output.Value 和 Output.Stdout 是两个东西:result 是协议规定的业务结果,print() 写到 stdout。运行结束后,Python 堆和本轮 Guest 状态不会被 Runner 复用;Runner 只保留可重复使用的编译代码、工具函数和可选的 prepared image。
4. 程序、进程和线程不是同一个词
这三个词经常被混用,但在本项目里它们对应不同层次。
程序通常指一份静态代码或 artifact。这里的 dist/spine.wasm 是一个程序 artifact;用户传入的 Python 源码也是程序文本。
进程是操作系统正在运行的资源单位。执行 go test 或 go run ./cmd/demo 时,操作系统启动的是一个 Go 进程。这个进程里可以有 wazero runtime、多个 Wasm 实例和 Go 的对象。Pysolate 当前没有为每次 Run 启动一个新的操作系统进程;一次 Run 创建的是新的 Wasm 实例。
线程是进程中可以被调度执行的一条路径。多个线程可以共享一个进程的地址空间,因此“线程不同”本身不等于“内存隔离”。Go 还会用 goroutine 表达轻量的并发工作单元;goroutine 与操作系统线程不是一一对应关系。PLM、prefix 和 Go 的取消/协作细节放到 concurrency.md,本章只需要记住:实例隔离和线程调度是两件事。
当前设计用“每次 Run 一个私有 Wasm 实例”隔离 Python 状态,而不是用“每次 Run 一个 OS 进程”。这也是为什么 Runner 可以复用编译后的 Wasm,又不复用上一轮的 builtins、模块和 Python heap。
5. Wasm runtime、虚拟机和容器的边界
这里的“虚拟机”容易让人想到完整的操作系统虚拟机。Wasm runtime 的范围小得多。wazero 负责加载和验证 Wasm 模块、创建实例、管理线性内存、连接导入函数,并按自己的执行引擎运行 Wasm。它不是启动了一整套 Linux;Guest 并不会自动获得 shell、宿主目录或任意系统工具。
容器通常仍运行在宿主操作系统的进程机制上,并借助操作系统提供的资源隔离、资源用量限制和文件系统配置来设置边界。Wasm 实例的首要边界是自己的线性内存和明确的导入;两者都能用于隔离,但机制不是一回事。不能因为 Guest 在“虚拟机”里,就推断它拥有一个可以自由访问的 Linux 系统;也不能把 wazero 当作容器运行时。
Pysolate 的 Host/Guest 关系如下:
- Host:外面的 Go 程序,持有 wazero runtime、工具注册表、取消上下文和输出 buffer。
- Guest:Wasm 实例里的 CPython、嵌入标准库、C bridge 和用户 Python。
- 边界:Guest 通过导入函数请求 Host;Host 通过 Wasm API 读写 Guest 的线性内存,并检查地址、长度和上限。
Guest 不能把一个数字直接当成 Go 的机器地址使用。它只能按约定传递 Guest 线性内存里的偏移量,再由 Host 通过 Wasm API 访问。这不是“把 Host 指针藏起来”,而是两边使用了不同的寻址规则;两份存储仍可由同一个 Go 进程持有,不是在说每个 Guest 都有一个独立 OS 进程。
6. Module、Instance 和 wazero
把 Wasm 模块想成一份可反复装载的模板,把实例想成一次实际装载。Runner.code 是编译后的模块代码;r.runtime.InstantiateModule 每调用一次,就得到一个新的 api.Module。不同实例可以使用同一份编译结果,但有自己的可写内存。
prepared.go 的注释明确写着:每个 Run 有私有 allocated memory;这里描述的是 full-copy,而非另一条 Linux COW 路径。NewPrepared 只把干净初始化后的线性内存保存成 Go 的 image;后续实例把这份字节复制进去。它不是把一个正在运行的实例借给多个 Run,也不是让多个实例共享可写页。更完整的内存解释见 memory-abi.md 和 prepared-copy.md。
关闭 Runner 之前必须等它负责的 Run 返回,因为 Runner 还拥有 runtime、编译代码和 prepared image。关闭一个实例会回收本轮的 Guest 资源;它不会把上轮 Python 的 global 留给下一轮。
7. Import、Export 和 WASI
7.1 我们自己的 Host import/export
在 guest/runtime.c 中,C bridge 声明了三个导入:
// 片段,非完整文件
__attribute__((import_module("spine"), import_name("call")))
int32_t host_call(const char *request, int32_t size, char *out, int32_t capacity);
这句话的意思是:Guest 里有一个叫 spine.call 的外部函数,调用时按这组参数约定传递请求位置、请求长度、输出位置和输出容量。它的实现不在 Wasm 内,而由 Go 的 NewHostModuleBuilder("spine") 注册。prepare 和 resolve 是同一组边界上的另外两个入口。
反过来,EXPORT(name) 给 C 函数指定 Wasm 导出名。Go 通过导出名调用 init、alloc、release 和 execute;_initialize 则来自工具链生成的初始化路径。导出是模块向外提供的函数,导入是模块向外索要的函数。只有名字和参数约定都匹配,调用才有意义。
7.2 WASI 提供接口不等于给权限
runner.go 安装 wasi_snapshot_preview1,因为 CPython 和 Wasm 运行所需的基础接口要有对应实现。WASI 可以提供文件描述符操作、时钟、退出等标准化接口;安装接口本身不等于把宿主机所有资源授权给 Guest。
当前 Run 没有把宿主目录 preopen 给 Guest,也没有把宿主环境变量、stdin、网络或进程能力作为本项目的执行输入。stdout 和 stderr 是由 wazero 配置到 Go buffer 的输出通道。Guest 能做什么,取决于实际安装和配置的导入,不取决于“它运行在一台机器上”。
CPython 标准库的文件也不是通过挂载宿主目录得到的。build-guest.sh 把 CPython Lib 复制到构建临时目录,再用 wasi-vfs 打进 Wasm,Guest 内看到的路径是 /usr/lib/python3.14。它是 artifact 内的嵌入文件,不是宿主机同名目录的实时映射。改宿主目录中的 guest/bootstrap.py 也不会让已经生成的 dist/spine.wasm 自动变化,必须重新构建并替换 artifact。
8. Reactor、初始化和 execute 的顺序
这个 Guest 按 reactor 模式构建。reactor 可以理解为“先装载模块,等 Host 明确调用导出函数”,而不是依赖一个自动运行的主函数完成全部工作。命令行程序也不一定等待 stdin;是否读输入是程序自己的行为。构建脚本的 -mexec-model=reactor 选择了这种模型;Go 实例化配置中的 WithStartFunctions() 使 Runner 不依赖默认 start function 来执行自己的生命周期。
本项目的顺序是:
InstantiateModule
→ _initialize
→ init(fresh 模式;启动 CPython 并导入 bootstrap)
→ execute(本轮请求;compile/exec 用户 Python)
prepared 模式只有参考 Guest 执行一次 CPython init;后续每个新实例先执行自己的 _initialize,再把干净的线性内存 image 全量写回。这个差别不改变用户程序最后仍从 execute 进入。
_initialize、init 和 execute 不能互换:前者完成模块级初始化,init 建立 CPython,execute 才有可调用的 Python 函数对象和用户请求。如果只实例化模块而不调用 init,并不代表 CPython 已经启动;如果先调用 execute,C bridge 找不到正确的 Python 执行函数。
读完这一章后,可以用一句话复述完整路径:Go Host 用 wazero 实例化一个 reactor Guest,Guest 的 C bridge 启动 CPython;CPython 在运行时把用户 Python 编成 code object 并 exec,工具调用通过显式 import 回到 Go,结果再沿 ABI 返回。