前面的教学包把一个需求拆成小版本。现在回到真实代码,按依赖、状态、调用和收尾把相关行连起来。
工具注册:闭包保存数据,map 选择行为
先读 demoTools:
cmd/demo/main.go · L31–L47 · 打开完整源码
func demoTools() spine.Manifest {
// Host owns this immutable snapshot. No tool is available unless registered.
catalog := map[string]any{"book": 21, "pen": 3, "shipping": 5, "choose": "pen"}
return spine.Manifest{"lookup": {Call: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
Key string `json:"key"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, err
}
price, ok := catalog[a.Key]
if !ok {
return nil, fmt.Errorf("missing key: %s", a.Key)
}
return price, nil
}, AllowEarlyRead: true}}
}
这一段同时用了三件事:
catalog保存本例的价格数据。- 匿名函数捕获
catalog,之后收到参数时从中查价。 - 返回的 map 把名字
lookup对应到这个函数值。
即使 demoTools 已经返回,闭包仍能使用它捕获的变量。Go 会保证仍被使用的数据保持存活。这里的价格表按约定保持只读,所以多个请求可以读取同一份表。
如果业务要求运行时修改价格,要先选定更新方式,例如在锁内修改,或替换一份不可变快照。直接加一次 map 写入,会改变当前的并发条件。
从 TS 看,这很像一个返回回调表的工厂函数。从 Java/C# 看,可以把它理解成入口处组装了一组可调用的工具。
练习:新增工具需要做什么?
先给注册表增加一个同签名函数,再让请求带上它的名字。若还希望它可以被提前执行,还需要满足本项目对稳定只读查询的约定,并显式加入允许名单。
构造函数:建立长期对象,复制注册表
runner.go · L33–L49 · 打开完整源码
type Output struct {
Value json.RawMessage
Stdout string
Transformed string // Optional actual Guest AST rendering for learning, not execution input.
}
var pythonIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
var pythonKeywords = map[string]bool{
"False": true, "None": true, "True": true, "and": true, "as": true, "assert": true,
"async": true, "await": true, "break": true, "class": true, "continue": true, "def": true,
"del": true, "elif": true, "else": true, "except": true, "finally": true, "for": true,
"from": true, "global": true, "if": true, "import": true, "in": true, "is": true,
"lambda": true, "nonlocal": true, "not": true, "or": true, "pass": true, "raise": true,
"return": true, "try": true, "while": true, "with": true, "yield": true,
}
New 做了几件相互关联的事:建立 runtime、创建 Runner 自己的 map、复制工具条目、检查提前读取名单。
复制 map 后,调用方再替换原注册表里的某个条目,不会改变 Runner 保存的那个条目。函数值内部捕获的数据仍按它原来的方式共享,因此前面的 catalog 只读约定仍然重要。
后续构造步骤还会注册 Host 模块和编译 Wasm。完成构造的 Runner 可以被多轮调用复用,调用方负责在所有运行结束之后关闭它。
这是一种常见安排:入口选择依赖,构造函数建立对象,方法处理每次调用。单次请求的输入和 context 继续通过参数传入。
完整过程见 Runner 带读。
每次 Run:局部状态加 defer
runner.go · L95–L118 · 打开完整源码
func (r *Runner) Close(ctx context.Context) error {
r.image = nil
err := r.runtime.Close(ctx)
if r.cow != nil {
err = errors.Join(err, r.cow.close())
r.cow = nil
}
return err
}
// Run owns the instance, I/O and all Guest allocations until it returns.
func (r *Runner) Run(ctx context.Context, source string, inputs any) (Output, error) {
return r.run(ctx, source, inputs, false, nil)
}
// RunPLM enables the built-in Guest AST pass, only for explicitly allowed snapshot reads.
func (r *Runner) RunPLM(ctx context.Context, source string, inputs any) (Output, error) {
return r.run(ctx, source, inputs, true, nil)
}
func (r *Runner) run(ctx context.Context, source string, inputs any, plm bool, chunks <-chan string) (Output, error) {
state := newRun(ctx, r, plm)
defer state.close()
Runner 保存长期资源。run 开始后另外创建 runState、输出缓冲和 Guest 实例,把这次调用需要的状态集中在本次执行中。
注意两处清理登记:
defer state.close()
// 创建本轮 Guest 成功后
defer m.Close(context.Background())
按当前源码,后登记的 Guest 关闭会先执行,随后 state.close 取消并等待 Host 工作。后面若成功取得返回缓冲,还会登记它的释放。这些 defer 在 run 返回前按逆序处理。
这段代码要求 Host 工作只使用自己持有的参数和响应。若后台工作还借用了 Guest 内存,就必须重新安排两者的生命周期。读并发代码时,先找谁还会访问一份数据,再判断关闭顺序。
context.Background() 在这里给关闭操作提供一个未被本轮请求取消的 context。正常执行仍使用调用方传入的 context。
prepare:把后台工作登记到本轮
future.go · L41–L59 · 打开完整源码
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)
}()
从上往下看这个组合:
- 先判断本轮能否提前读取,不能准备时返回
0。 - 为任务派生 context,创建 Future 和完成通道。
- 在 map 中保存任务,返回的数字用来定位它。
- 在启动 goroutine 前调用
workers.Add(1)。 - 工作函数写好响应,退出时关闭通知并完成计数。
三个 defer 也有明确顺序:先关闭 done,再取消任务 context,最后减少工作计数。响应在函数体里写好,等待方收到关闭通知后可以读取它。
本项目由 Guest 调用线程修改 Future 注册表,worker 发布自己那一份响应。这个访问安排解释了为什么这里没有给整张 map 加 mutex。把 map 交给多个 goroutine 同时修改时,就需要重新处理同步。
resolve:取得一次结果,同时处理取消
future.go · L62–L83 · 打开完整源码
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())
}
resolve 先处理普通执行路径,再查找并移除句柄。移除发生在等待之前,因此同一个句柄只能领取一次。
请求一致时,select 等待两种情况:任务完成,或本轮取消。如果都已经就绪,Go 可以选择其中任一可执行分支;代码没有承诺固定优先级。
最后两个分支只决定这次等待返回什么。后台任务的最终收尾还要看 runState.close:
future.go · L36–L39 · 打开完整源码
func (s *runState) close() {
s.cancel()
s.workers.Wait() // Cooperative tools finish before Run returns, including discarded reads.
}
因此,看到“取消分支返回了”之后还应继续追踪外层 defer。这个项目在 Run 返回前等待属于本轮的工作退出。
完整讲解见 Future 带读。
函数值也可以临时保存一种选择
cmd/demo/main.go · L69–L82 · 打开完整源码
tools := demoTools()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
newRunner := spine.New
if prepared {
newRunner = spine.NewPrepared
} else if preparedCOW {
newRunner = spine.NewPreparedCOW
}
runner, err := newRunner(ctx, wasm, tools)
if err != nil {
return err
}
defer runner.Close(context.Background())
入口先把 spine.New 放进 newRunner,根据选项替换成另一种构造函数,再统一调用。三者签名一致,所以后面的错误检查和关闭安排可以共用。
这里用一个局部函数变量就表达了选择。将来若不同模式需要完全不同的配置、初始化步骤和生命周期,再根据实际差异调整结构。
从这里开始改一个小功能
可以按下面顺序练:
- 新增一个普通工具,验证正确请求与错误参数。
- 给工具包装一层函数,记录调用次数;测试确认它仍返回原值和原错误。
- 写一个会等取消通知的测试工具,确认 Run 返回前它已退出。
- 分别说明哪些变量属于 Runner、Run 和单个 Future。
每次只改一项,并在测试中确认相应行为。遇到新要求时,先沿现有的所有权和调用路径找位置,再决定是否需要增加新的类型。