这一章回答一个具体问题:源码还在通过 channel 到达时,Host 如何让同一个 Guest 先做可提前的工作,等源码结束后再执行完整程序?
先沿一条完整因果路径看结果:调用者把字符串片段按顺序发送给 RunPrefix;入口检查流和 Guest 导出后,交给共用的 run。run 为本轮创建一个 runState 和一个 Guest,先把 source、inputs、plm 和当前工具清单作为一次 prefix_begin 请求送入这个 Guest。之后每个非空片段通过 prefix_feed 送入同一个模块。Guest 追加片段、识别已经完整的前缀调用,并通过 prepare 让 runState 启动 Future worker。worker 可以在 producer 发送下一段之前进入 Host 工具,因此源码接收和提前工作发生了重叠。channel 关闭后,Host 不再传送第二份完整源码,而是在同一个模块上调用 execute;Guest 从自己的 Prefix 状态取回完整 source、inputs 和 manifest,编译并执行。最终工具调用按请求和队首顺序领取 handle,run 复制结果,收尾时取消并等待仍在运行的 worker。
这里的“两阶段”是同一实例上的源码接收/提前准备阶段,再接最终执行阶段,不是两个 Guest。prefix.go 只负责 Host↔Guest 的时序和临时 ABI 内存;AST 形状、完整行边界和 ready 队列在 prefix-guest.md。
1. 先看 Host 交付的对象
RunPrefix 的输入不是完整字符串,而是只读方向的 <-chan string。调用者发送一个片段就追加一段源码,关闭 channel 才表示不会再有片段。发送动作本身不执行 Python。ctx 同时控制源码接收、Guest 导出调用和 Host 工具 worker;它不要求 producer 先关闭 channel 才能取消。
run 还会把 Go 侧的 Manifest 转成 Guest 可读的列表,每项只有 Python 需要的 name 和 allow_early_read。Host 仍保留带有 Call 实现的完整 manifest;Guest 只据此决定哪些具名函数可以进入 prefix 提前读取。这个转换发生在 runner.go,本章只沿 prefix.go 负责的调用边界继续追踪。
RunPrefix(ctx, chunks, inputs)
→ run:创建本轮 runState 和一个 Guest
→ receiveSource:prefix_begin(initial JSON)
→ prefix_feed(chunk₁)
→ prefix_feed(chunk₂) …
→ chunks 关闭
→ 同一个 Guest:execute({"prefix": true})
→ run:复制结果,取消并等待 worker
initial JSON 包含空的 source、本轮 inputs、plm: true 和 Guest manifest。接收完成后,run 把下一次请求缩成 {"prefix":true},因为完整 source 和输入已经存放在这个 Guest 的 Prefix 对象中。
2. 入口先确认协议,再交给共用执行器
prefix.go · L1–L12 · 打开完整源码
package spine
import (
"context"
"errors"
"fmt"
"github.com/tetratelabs/wazero/api"
)
// RunPrefix receives append-only UTF-8 source fragments. Closing chunks commits
// the source for one execution in the same Guest; ctx cancels an unfinished stream.
这一段只建立包、依赖和公开入口的契约。context 提供取消,api.Module 表示本轮 Guest;RunPrefix 依赖的 Runner、Output 和 run 都在同一个包里。注释明确限定源码只能追加,关闭流才提交本轮源码。
这里先检查 chunks == nil。nil channel 的接收会永久等待,和“已经关闭的空 channel”不同,所以入口把它作为调用错误拒绝。随后检查 prefix_begin、prefix_feed 两个导出是否存在,避免 Host 按新协议驱动不具备对应入口的旧 artifact。
prefix.go · L13–L23 · 打开完整源码
func (r *Runner) RunPrefix(ctx context.Context, chunks <-chan string, inputs any) (Output, error) {
if chunks == nil {
return Output{}, errors.New("nil source stream")
}
for _, name := range []string{"prefix_begin", "prefix_feed"} {
if r.code.ExportedFunctions()[name] == nil {
return Output{}, fmt.Errorf("missing prefix Guest export: %s", name)
}
}
return r.run(ctx, "", inputs, true, chunks)
}
RunPrefix 不创建 runtime、Guest 或 runState。它只把 ctx、源码流、输入和 true 传给 r.run;这个 true 打开本轮的 whole-program PLM 能力,但源码流的接收仍由 receiveSource 完成。因而 prefix 继续使用普通 Run/RunPLM 共用的 Future、结果复制和关闭路径。
一个容易混淆的边界是:入口检查通过,只说明 Host 可以调用两个导出;它还没有读取任何 chunk,也没有启动工具。真正的准备起点要等 Guest 处理某个完整候选并回调 Host 的 prepare。
3. receiveSource:先初始化,再按片段推进
prefix.go · L25–L33 · 打开完整源码
func receiveSource(ctx context.Context, m api.Module, initial []byte, chunks <-chan string) error {
status, err := callWithBytes(ctx, m, "prefix_begin", initial)
if err != nil {
return err
}
if status[0] != 0 {
return errors.New("prefix initialization failed: ")
}
received := len(initial)
receiveSource 接收同一个 api.Module、初始 JSON 和只读 channel。第一步调用 prefix_begin,把本轮初始状态交给 Guest。调用失败直接返回;状态字的非零值被解释为 prefix 初始化失败。代码直接读取 status[0],因此它依赖该 Guest ABI 至少返回一个状态值。
received 从 len(initial) 开始,而不是从零开始。Host 的 1 MiB 预算包含初始 JSON,也包含后续源码片段;但超限检查在真正调用 prefix_feed 之前发生,所以被拒绝的片段不会改变 Guest 的 source 或 ready 状态。
prefix.go · L34–L45 · 打开完整源码
for {
select {
case <-ctx.Done():
return ctx.Err()
case chunk, ok := <-chunks:
if !ok {
return ctx.Err()
}
received += len(chunk)
if received > maxMessage {
return errors.New("prefix input exceeds 1 MiB")
}
循环每次用 select 等两个事件:
ctx.Done()就返回ctx.Err(),源码还没有完成接收也可以停止;- 从
chunks收到字符串时,第二个返回值ok区分普通片段和 channel 关闭。
关闭 channel 时同样返回 ctx.Err()。正常关闭且上下文仍健康时,ctx.Err() 为 nil,这就把“源码提交完成”交回 run;若上下文已取消,调用者得到取消错误。这里没有 busy loop,也没有因为等待 producer 而暂停已经启动的 worker:worker 使用自己的 goroutine,并通过同一个 run context 协作取消。
每个 chunk 按 UTF-8 字节长度计入预算。超过 maxMessage 后立即返回固定的 1 MiB 错误;空 chunk 只经过预算计算,随后 continue,不会产生一次 prefix_feed,所以不会因为空片段重复解析。
prefix.go · L46–L59 · 打开完整源码
if chunk == "" {
continue
}
status, err = callWithBytes(ctx, m, "prefix_feed", []byte(chunk))
if err != nil {
return err
}
if status[0] != 0 {
return errors.New("prefix feed failed: ")
}
}
}
}
非空片段才调用 prefix_feed。这个导出完成后,Host 检查返回状态;非零状态立即结束源码接收,成功则回到循环等待下一片段。源码累计文本、已发现候选和 ready 队列都归 Guest Prefix 对象,Host 不维护第二份 prefix 解析状态。
因此这里有两个不同的完成点:channel 关闭只完成了输入交付,没有执行程序;receiveSource 返回后,run 才在同一个模块上调用 execute。最后一段没有换行时,Guest 可能没有为它提前准备,但最终 compile 仍会以完整 source 判断它是否合法。
4. 并发从哪里开始,为什么 producer 能继续发送
以测试中的首段为例,实际顺序是:
producer 发送 price=lookup(key=inputs['item'])\n
→ receiveSource 调 prefix_feed
→ Prefix.feed 通过 Manifest 认出 lookup 可提前读取
→ Guest 调用 Host prepare
→ runState.prepare 创建 Future、启动 goroutine
→ worker 进入 read,递增 calls["book"],发出 started
→ producer 才发送剩余源码
→ channel 关闭后,同一 Guest execute
→ 最终 lookup 调 claim/resolve,领取刚才的 handle
所以“并发起点”不是 RunPrefix 被调用,也不是 producer 创建 goroutine;对这条路径来说,可观察的工作起点是 runState.prepare 启动的 worker,测试中的 started 更严格:它只在 worker 已经进入真实 Host read 后才发出。receiveSource 仍可阻塞等待下一段,而 worker 独立运行。若 producer 反过来等待 started,就能证明实现没有把所有准备推迟到 EOF。
同一请求最终只会被领取一次:Prefix 保存的是本轮 runState 返回的整数 handle,不是工具值。最终执行时若请求和队首条目完全匹配,才进入共同的 resolve;Future 的错误、取消和单次消费规则仍由 Host 的 runState 负责。
5. callWithBytes:每次导出调用的临时搬运
prefix.go · L60–L72 · 打开完整源码
// Request memory is temporary for this call; returned Guest response memory, if any,
// is owned by its caller. Shared by execute and the two prefix setup calls.
func callWithBytes(ctx context.Context, m api.Module, name string, data []byte) ([]uint64, error) {
ptr, err := m.ExportedFunction("alloc").Call(ctx, uint64(len(data)))
if err != nil {
return nil, err
}
if ptr[0] == 0 || !m.Memory().Write(uint32(ptr[0]), data) {
return nil, errors.New("Guest request allocation failed")
}
defer m.ExportedFunction("release").Call(ctx, ptr[0])
return m.ExportedFunction(name).Call(ctx, ptr[0], uint64(len(data)))
}
callWithBytes 是 prefix_begin、prefix_feed 和 execute 共用的 ABI 辅助函数。它先调用 Guest 的 alloc 申请恰好容纳请求的线性内存,再把 Go 拥有的字节写入 Guest。地址为零或写入越界都会在调用目标导出前失败。
defer 把 release 绑定到这个 Go 函数的返回点,因此 alloc、写入或 Guest 导出提前失败时,请求缓冲区仍会释放。这个 release 只负责本次请求内存;execute 返回的结果指针属于调用者,由 run 读取、复制后再释放。Prefix 的长期状态不在这块临时请求内存里,而在同一个 Guest 实例中的 _prefix/Prefix 对象里。
函数最后按名称调用导出并原样返回整数切片。对两个 prefix setup 导出,切片第一个整数是状态;对 execute,返回值是长度和指针打包后的响应描述。Host 接收阶段不解析 AST,也不解码业务结果。
6. 失败和收尾落在哪个阶段
- nil channel 和缺少导出:
RunPrefix尚未创建本轮执行,立即返回。 - 初始请求、Guest 导出或 context 失败:
receiveSource返回,run的统一失败路径带上 stdout。 - 累计输入超过 1 MiB:在当前 chunk 进入 Guest 前返回。
- Guest 解析暂时不能完成:由
guest/prefix.py保留 source,等待后续片段;最终完整程序的语法错误在execute的compile阶段返回。 - 某个提前工具没有成功获得 handle:Prefix 不把它放进 ready,最终调用回到普通路径;已启动但未领取的 Future 仍由
runState.close取消并等待。
这也解释了为什么不能把“Run 已返回”直接等同于“worker 已退出”:本轮 run 的 defer 会先取消 context,再等待 worker 完成。只有这个统一收尾完成,调用者才拿到最终的 Output/error。
下一步读 prefix-guest.md:它解释 Manifest 如何变成 early_names、何时认为语句完整,以及为什么扫描一旦遇到首个非候选语句就停止。随后用 prefix-tests.md 对照真实 Guest 的握手、Future 领取和取消场景。
读完本章应能回答:
- 为什么 prefix 接收和最终执行必须共享同一个 Guest?
started发生在源码发送、prepare调用和 Host worker 进入工具的哪一个时刻?- 哪些错误在 chunk 进入 Guest 前发生,哪些要等最终
compile或统一收尾?