本章回答一个具体问题:PLM 把一个 Python 工具调用拆成“先启动、原位领取”后,Go 到底保存了什么,工具任务何时算完成,结果为什么仍在原来的 Python 语句处交付?
调用者是 Runner.run:它为每次 RunPLM 创建一个新的 runState,再把这个 state 放进本轮 context。Guest 的 prepare 和 resolve 通过同一条 Host bridge 回到这里。输入是已经从 Guest 线性内存解码出的 callRequest;最终交付对象是 resolve 返回的 JSON response,而不是 Go worker 直接改写 Python 变量。
先记住三层边界:
Runner.manifest是 Host 工具注册表:工具名映射到ToolSpec,其中Call是实现,AllowEarlyRead是 Host 对提前读取的显式承诺。runState.futures是本轮的 handle 登记表。整数只在这一轮有效,表项只允许领取一次。- Python 只拿到整数 handle。它拿不到
future指针、response字节或 Go map;真正的值/错误要等原调用位置执行resolve后再解码。
本章按一次 a=lookup(...); b=lookup(...) 的因果顺序读源码。source 标记覆盖 future.go 的全部 84 行;正文不按行号逐条翻译,而按类型、准备、领取和收尾四个职责块说明。
1. 先把所有权放对位置
这段声明回答“谁能修改登记表,谁在后台工作,以及一次 Run 结束时要等谁”。
future.go · L1–L27 · 打开完整源码
package spine
import (
"bytes"
"context"
"errors"
"sync"
)
// One owner per Run. Only the Guest thread edits the map; workers publish via done.
// Early tools promise a stable, read-only snapshot for this run and must honor ctx.
type runState struct {
runner *Runner
ctx context.Context
cancel context.CancelFunc
enabled bool
next uint32
futures map[uint32]*future
workers sync.WaitGroup
}
type future struct {
request callRequest
response []byte
done chan struct{}
cancel context.CancelFunc
}
runState 属于一次 Run,而不是长期存在的 Runner。它保存本轮 context、是否启用 PLM、下一个 handle、Future map 和 worker 计数器。因而两个并发的 Run 即使都分配到整数 1,也不会看到彼此的 Future。
future 保存四样东西:原始 callRequest、工具完成后编码好的 response、完成信号 done 和该工具自己的 cancel。request.Args 已经是 Go 拥有的 JSON 字节;worker 不再读取 Guest 内存,也不会把结果写回 Python 堆。
这里有一个重要的并发分工。当前 Guest 调用 prepare/resolve 的路径负责读写 futures map;每个 worker 只写自己那一个 Future 的 response,然后关闭自己的 done。代码没有让多个 worker 并发改同一张 map。done 关闭是交接点:等待方看到它就绪后,才能读取已经写入的 response。
2. 每次 Run 建一个新的登记板,并在返回前收尾
newRun 是一次执行的起点;close 是无论成功、Python 报错还是未领取 Future 都要经过的终点。
future.go · L29–L39 · 打开完整源码
type runKey struct{}
func newRun(ctx context.Context, r *Runner, enabled bool) *runState {
ctx, cancel := context.WithCancel(ctx)
return &runState{runner: r, ctx: ctx, cancel: cancel, enabled: enabled, futures: make(map[uint32]*future)}
}
func (s *runState) close() {
s.cancel()
s.workers.Wait() // Cooperative tools finish before Run returns, including discarded reads.
}
newRun 先从调用者 context 派生子 context,再创建空的 futures map。enabled 来自调用入口:普通 Run 传 false,RunPLM 传 true。所以“Runner 注册过可提前读取工具”和“本轮确实打开 PLM”是两个条件。
close 先取消本轮根 context,再调用 workers.Wait()。这不是等待 Python 领取结果,而是等待所有已经启动的 Go worker 退出,包括 Guest 已经不再使用、但仍在等待 context 的读取。只有 worker 合作观察 context 并返回,Run 才能完成收尾;不能把 cancel() 本身写成强杀任意 Go 函数的保证。
典型状态序列如下:
newRun(..., true)建立空表,next=0。- 第一个
prepare登记 handle1,第二个登记 handle2;两个 worker 都计入 WaitGroup。 - Python 仍按源程序执行到第一条
resolve,领取1;第二条随后领取2。 - Guest 执行结束后,
state.close取消剩余工作并等待 worker;最后 Run 返回。
3. prepare:先过三道门,再登记并启动 worker
现在看一次 prepare(request) 的完整动作。请求已经由 bridge 从 Guest 内存解析成 callRequest,所以这里的工作是“决定是否允许提前、分配本轮 handle、启动 Host 工具”,不是再执行 Python。
future.go · L41–L61 · 打开完整源码
func (s *runState) prepare(request callRequest) uint32 {
// 64 pending reads is a demo resource policy, not a Wasm limit.
spec, ok := s.runner.manifest[request.Tool]
if !s.enabled || !ok || !spec.AllowEarlyRead || len(s.futures) >= 64 {
return 0 // Explicitly not prepared; original call will execute normally.
}
ctx, cancel := context.WithCancel(s.ctx)
f := &future{request: request, done: make(chan struct{}), cancel: cancel}
s.next++
id := s.next
s.futures[id] = f
s.workers.Add(1)
go func() {
defer s.workers.Done()
defer cancel()
defer close(f.done)
value, err := spec.Call(ctx, request.Args)
f.response = encodeResponse(value, err)
}()
return id
}
第一道门是 s.enabled:只有 RunPLM 或复用这条路径的模式才可能启动 Future。第二道门在 Go 登记板上查 request.Tool:未知名字没有 ToolSpec。第三道门检查 spec.AllowEarlyRead 和当前 pending 数量;最多保留 64 个尚未消费的 Future。任一条件不满足都返回 0,这表示“没有准备成功”,不是一个可领取的 Future。
AllowEarlyRead 不是工具名字自动带来的性质。Go 调用者用当前具名 Manifest 登记:例如 Manifest{"lookup": {Call: read, AllowEarlyRead: true}}。这个字段表达 Host 对该实现的协作承诺:它可以安全地被提前启动、允许结果被丢弃,并且应响应本轮 context。future.go 在准备时仍会再次查 Go 的 Manifest;Guest 侧的 AST 选择和 Host 侧的运行时门槛不能互相替代。
通过检查后,代码从本轮 context 派生一个工具专属 context,创建 Future,把 next 加一,并将指针写入 s.futures[id]。先登记再启动很关键:worker 可能很快完成,但原调用位置还必须能凭这个 handle 找到它。
随后 workers.Add(1) 建立收尾计数,go func() 才启动工具任务。worker 的顺序是:调用 spec.Call(ctx, request.Args),把值或错误编码成 response,写入 f.response,函数返回时关闭 f.done,再执行取消和 Done 的 defer。因而“worker 已完成”在这个机制里指工具调用和 response 编码都结束、done 已关闭;仅仅执行到 go 语句,不能称为完成。
这个 worker 运行的是注册的 Go 工具,不是 Python thunk。Python lambda 只在 Guest 线程里读取参数并构造请求;Go worker 只接触已解码的 JSON 参数。两个独立调用是否真的重叠,由 Go 调度决定,但第二个 worker 不必等第一个结果才有机会启动。
4. resolve:按当前请求领取,而不是按源码位置盲取
resolve 出现在被改写后的原 Python 语句处。它收到 handle 和当前再次编码的工具请求,返回给 bridge 的仍是一份 JSON response。
future.go · L63–L84 · 打开完整源码
func (s *runState) resolve(id uint32, request callRequest) []byte {
if id == 0 {
return s.runner.invoke(s.ctx, request)
}
f := s.futures[id]
if f == nil {
return encodeResponse(nil, errors.New("unknown or consumed future"))
}
delete(s.futures, id) // Single-use handle, not a static source-site slot.
defer f.cancel()
if f.request.Tool != request.Tool || !bytes.Equal(f.request.Args, request.Args) {
f.cancel()
// Only registered snapshot reads were started early. Never reuse mismatched data.
return s.runner.invoke(s.ctx, request)
}
select {
case <-f.done:
return f.response // Includes tool errors: do not retry a failed operation.
case <-s.ctx.Done():
return encodeResponse(nil, s.ctx.Err())
}
}
id==0 是 prepare 的显式回退:代码直接走 s.runner.invoke,所以参数或权限不适合提前时,原工具仍能在当前位置普通执行。
非零 handle 先查本轮 map。查不到包括两种情况:数字从未登记,或已经被前一次 resolve 删除;两者都返回 unknown or consumed future。找到后立即 delete,把单次消费写进状态转换,而不是等工具完成后才删除。这样同一个 handle 的第二次领取不会再次得到同一结果。
接着比较登记时的工具名和参数 JSON。匹配单位是工具名字加 JSON 字节,不是源码行号,也不是模糊的业务等价性。失配时取消旧的只读工作,并用当前请求调用 invoke;旧 Future 的结果绝不能冒充当前调用的结果。这里的普通调用是有意的回退,而不是自动重试旧操作。
匹配时,select 等待两个事件之一:
f.done关闭:worker 已写好 response,resolve原样返回它。response 中的工具错误也留在这里,不会因为提前失败而自动再调用一次。s.ctx.Done()就绪:本轮被取消,返回一个取消错误 response。
defer f.cancel() 负责在领取结束后撤销该 Future 的子 context;如果工具已完成,这只是幂等的清理;如果发生失配,也不会让旧读取继续被当作有用工作。
5. 为什么结果和错误仍在原位交付
PLM 保留 Python 赋值的位置,把右侧的具名工具调用(例如 lookup(...))换成 resolve helper。因此 Python 仍按原来的顺序执行赋值、进入 try/except,并把返回值绑定到同一个变量。
以两条独立读取为例,因果链是:
- 两条读取相邻且参数彼此独立时,AST 将两个
prepare都放在组开头,先后启动两个 Go worker,然后才执行第一条原赋值中的resolve。 - Python 执行第一条原赋值时调用
resolve(1, 当前请求)。如果 worker 尚未结束,resolve 等待;结束后把 JSON value 解码成第一条赋值的值。 - Python 执行第二条原赋值时以同样方式领取第二个 response。
- 最后的
result仍由 Python 读取两个变量计算。
错误也遵守同一位置:worker 可以提前把 error 编进 response,但 resolve 只在原调用位置把这份 response 交给 bootstrap;bootstrap 的 decode 才把它变成 Python 异常。这样第一条赋值已经发生时,except 才能读取它。参数表达式本身也会在原语句重新求值,因此缺失 inputs 键仍在原调用处抛出,而不是由提前准备阶段越过前面的控制流。
这正是 Future 与“提前把结果写进变量”不同的地方:Future 提前执行的是 Host 读取,原 Python 调用仍拥有值、错误、变量绑定和异常控制流的交付位置。
6. 收束边界:这些状态由测试直接证明
future.go 只提供本轮状态机;它不证明 AST 一定选中了正确调用,也不证明每个 Host 工具真的满足稳定快照承诺。证据要分层看:
guest/test_plm.py用替身检查 AST 插入位置和事件顺序:两个独立调用先 prepare、再按原序 resolve;依赖调用不能越过定义;分支不会把未选路径搬出来。plm_test.go用真实dist/spine.wasm检查 Go 登记板、bridge、bootstrap 和这份 Future 状态机是否接通:第二个工具必须先发出启动握手,第一个才能返回;失败在原调用处进入 handler;未领取 worker 被取消并在 Run 返回前退出。- 同一组 Go 测试还直接检查 handle 语义:参数失配不复用、handle 不跨 Run、同一 handle 只能领取一次、普通 Run 不 prepare、超过 64 个 pending 请求返回 0;未 opt-in 的
lookup则按当前 Manifest 不进入 PLM 改写并正常调用。
不要从这套实现推出“所有工具都可安全并发”或“所有 worker 都能被强制终止”。能够成立的结论更窄:在 Host 已作出 AllowEarlyRead 承诺、工具配合 context、请求仍匹配的前提下,某个本轮 handle 可以把一次 Host 读取提前启动;结果和错误仍在原 Python 调用位置领取。
下一步阅读 plm-pass.md,看 AST 如何决定哪些具名调用进入这条状态机;再读 plm-tests.md 和 plm-python-tests.md,分别核对真实 Guest 边界与本地 pass 证据。相关的 JSON/线性内存交接见 bridge.md,Runner 如何把 Manifest 投影给 Guest 见 runner.md。
读完本章应能回答:
- 为什么整数 handle 只能在当前 Run 领取一次?
done关闭前,worker 必须完成哪些写入?- 为什么提前失败不能直接在 prepare 阶段抛给 Python?