把 Python Evaluator 放进 WASM:Shimmy 的当前实现
三月写 Shimmy 的第一版 WASM 方案 时,我关心的是一件很直接的事:如果 AWS Lambda 里拿不到 namespace、seccomp filter 和 cgroup 控制权,还能不能在应用内部再放一层边界?
当时的答案是 WASM。这个方向没变,但实现和我对它的理解都变了不少。
我现在不会再用“WASM 里没有 syscall”来概括安全模型。更准确的说法是:guest 不能直接向 host kernel 发任意 syscall;它只能调用 Host 在实例化时提供的 imports。一个模块即使导入了 WASI 的 path、socket 或 clock 函数,也不等于得到了对应资源。真正的 authority boundary 是 Host 如何构造实例。
这篇不再介绍 WASM 是什么,只写当前方案是怎样接进 Shimmy 的、Python 为什么需要单独的 reactor profile,以及 snapshot 到底能承诺什么。
先把编译和执行拆开
Shimmy 的 runtime 不应该知道 Rust、C++ 或 Python 分别怎样构建。它只消费一个已经准备好的 artifact:
source language
│ language-specific build recipe
▼
wasm32-wasip1 artifact
│ Shimmy JSON ABI
▼
Shimmy + wazero
所以“支持 Rust”或“支持 Python”不是 runtime interface。真正的层次是:
wasm是执行接口;generic、python-reactor是 artifact profile;- 各语言自己的 compiler、wrapper 和 packaging 属于部署阶段。
这个拆分省掉了很多错误抽象。Shimmy 不负责在请求到来时运行 cargo build 或下载 Python package,也不根据 requirements.txt 猜应该选哪个后端。部署方选择并验证 artifact,runtime 只执行。
通用路径:编译一次,实例化多份
启动时,dispatcher 做四件事:
read .wasm
→ instantiate Host imports
→ compile module once
→ create N pre-initialised instances
所有实例共享 wazero 的 compiled module,但各自有独立 linear memory。请求到来后,dispatcher 从 pool 取一个 supervisor,调用 guest,恢复请求前状态,再把健康实例放回去。
Host 侧 ABI 很小:
memory
alloc(size) -> pointer
evaluate(request_ptr, request_len) -> response_ptr
request_ptr 指向 JSON;返回值指向一个 length-prefixed JSON buffer。Host 会重新检查 pointer、length 和 response bounds,而不是相信 guest 给出的地址。
WASI context 默认不继承 filesystem、environment、stdin/stdout 或 argv。只读目录和环境变量必须显式列入配置。请求 timeout 由 Go context 控制;超时或 cancellation 会关闭正在运行的 module,实例随后被丢弃。
这里有一个容易被忽略的点:pool 复用的是 compiled code 和可恢复的实例,不是一份大家共同修改的 Python heap。一个 supervisor 同一时间只处理一个请求,pool 负责并发。
Python 不是另一套沙箱
CPython/WASI reactor 仍属于 WASM 执行路径,但它使用独立的 ReactorPythonDispatcher 和 artifact profile,并不是给 generic Dispatcher 多传一个参数。Dispatcher 会共享已加载的 WASM bytes;每个 pool runner 仍然要分别实例化并初始化 CPython。
它优先对齐通用 JSON ABI,同时保留早期 reactor artifact 的兼容路径:
py_init() # 启动 CPython
py_prepare(script) # 加载可信 evaluator 及其 imports
alloc / evaluate # 首选的通用 JSON ABI
alloc / py_exec / resp_* # legacy reactor 兼容 ABI
初始化流程大致如下:
instantiate CPython/WASI
→ _initialize
→ py_init
→ load selected evaluator
→ import only what that evaluator imports
→ reserve request heap headroom
→ snapshot
为什么要在 import 后 snapshot?早期版本每次请求都重新执行 evaluator script。NumPy workload 的 steady request 因此约为 6.64 s,大部分时间都花在重复 import。把部署方选定的 evaluator 和它的 import graph 移到可信准备阶段后,同一 workload 的 steady mean 降到 68 ms。
这不是“CPython/WASI 突然比 native Python 快了一百倍”。初始化没有消失,只是被前移:该 NumPy 配置的 server preparation 约为 35.9 s。如果实例只执行一两次就被回收,这笔预付成本并不划算。
也不能把 68 ms 和 Pyodide 的 2 ms warm request 直接排在一张速度榜上。前者每次恢复 prepared state;后者来自保留 Node/Pyodide interpreter 和 package cache 的 persistent worker。生命周期不同,数字回答的不是同一个问题。
Snapshot 不是撤销键
当前实现只在 guest export 已经返回 Host 后恢复状态。这个时刻没有活跃的 WASM call frame,是一个 quiescent boundary。
在这个边界上,状态被分成四类:
- 调用栈和临时执行帧已经自然 unwind;
- 纳入这条复用路径的 artifact 必须把 request-mutable state 放在可恢复的 linear memory 中;
- filesystem、network 等 authority 没有被授予,或只读存在;
- mutable globals、tables、Host handles 等未被证明可恢复的状态,必须拒绝或让实例出池。
Snapshot 无法撤销已经发生的外部写入。这个限制和实例是否被丢弃无关。
所以“复制 linear memory 就能重置任意 WASM 程序”是错的。Snapshot claim 绑定的是 具体 artifact、具体 Host imports 和具体 lifecycle。
memory.grow 是一次很实际的教训。WASM memory 可以增长,却不能缩回 snapshot 时的尺寸。如果只把旧 snapshot 覆盖到前缀,新增的 tail 仍可能留下上个请求的数据。
当前处理是 fail-closed:
post-request memory size > snapshot size
→ zero the grown tail
→ mark supervisor unhealthy
→ discard it
→ create a replacement
Python reactor 会在 snapshot 前预留一段 heap headroom,减少普通请求触发增长的概率;这只是优化,不会放宽检查。请求超过 headroom,runner 仍然作废。
为什么默认策略还是 full copy
我测试过四类 restore strategy,结果并没有出现一个“全面更快”的答案。
| Linear memory | Full copy | Soft-dirty(1% dirty) | UFFD |
|---|---|---|---|
| 128 MiB | 约 36 ms | 约 3.7 ms | 约 35 ms |
| 256 MiB | 约 72 ms | 约 4.0 ms | 约 72 ms |
| 512 MiB | 约 144 ms | 约 6.3 ms | 约 144 ms |
这些是 Linux synthetic benchmark,只隔离 restore mechanics,不包含 Python 执行或 Lambda cold start。
Soft-dirty 确实会随 dirty pages 缩放,但当前实现读取的是 process-wide 状态,无法安全地区分多个并发 instance。mprotect 版本需要进程级 SIGSEGV handler,也有同样的归属问题。UFFD/write-protect 在测试环境可用,却没有带来 restore-time 改善;当前实现仍接近 full copy。
因此默认值是最无聊的 full copy。它的成本与总内存线性相关,但并发语义清楚。Soft-dirty 和 UFFD 留作实验选项,而不是为了 benchmark 数字把不完整的机制推成默认。
最后得到的不是一个万能后端
WASM 路径现在覆盖两类 workload:
- 已经实现 Shimmy ABI 的 generic WASI evaluator;
- 可以放进受支持 CPython/WASI artifact 的 Python evaluator。
更重的 SciPy/Pandas package 仍可能需要 Pyodide,但当前 persistent worker 不能提供独立不可信请求所需的 freshness。无法合理编译为 WASM 的 native runtime 则需要另一条兼容路径,并承担不同的启动成本和 policy 风险。
这也是我最后放弃“选出唯一赢家”的原因。Runtime portfolio 看起来没有一句“全部统一到 WASM”那么漂亮,但更接近真实系统:
WASM-first
├── generic WASI
└── prepared CPython/WASI
compatibility paths
├── Pyodide for broader packages
└── transient native runtime for non-WASM evaluators
WASM 在这里解决的不是所有语言和依赖,而是给一组明确 artifact 提供窄、可测、可以 fail closed 的执行边界。
做完这一轮后,我留下来的检查表反而很短:编译和执行分开;authority 由 Host 授予;性能数字必须带 lifecycle;恢复不了的状态就丢掉实例。WASM 让这些边界比较容易落地,但不会自动替我们证明。