PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
CODE READING / 12

Future:本轮任务登记与交付

future.go

本章目录
配套饭点图解 · 4 节(按需展开)

本章回答一个具体问题: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 函数的保证。

典型状态序列如下:

  1. newRun(..., true) 建立空表,next=0。
  2. 第一个 prepare 登记 handle 1,第二个登记 handle 2;两个 worker 都计入 WaitGroup。
  3. Python 仍按源程序执行到第一条 resolve,领取 1;第二条随后领取 2。
  4. 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。

读完本章应能回答:

  1. 为什么整数 handle 只能在当前 Run 领取一次?
  2. done 关闭前,worker 必须完成哪些写入?
  3. 为什么提前失败不能直接在 prepare 阶段抛给 Python?