PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
GO PATTERNS / 04

在 Spine 中认出这些组合

回到工具注册、Runner 和 Future 的实际源码。

本章目录

前面的教学包把一个需求拆成小版本。现在回到真实代码,按依赖、状态、调用和收尾把相关行连起来。

工具注册:闭包保存数据,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)
	}()

从上往下看这个组合:

  1. 先判断本轮能否提前读取,不能准备时返回 0。
  2. 为任务派生 context,创建 Future 和完成通道。
  3. 在 map 中保存任务,返回的数字用来定位它。
  4. 在启动 goroutine 前调用 workers.Add(1)。
  5. 工作函数写好响应,退出时关闭通知并完成计数。

三个 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,根据选项替换成另一种构造函数,再统一调用。三者签名一致,所以后面的错误检查和关闭安排可以共用。

这里用一个局部函数变量就表达了选择。将来若不同模式需要完全不同的配置、初始化步骤和生命周期,再根据实际差异调整结构。

从这里开始改一个小功能

可以按下面顺序练:

  1. 新增一个普通工具,验证正确请求与错误参数。
  2. 给工具包装一层函数,记录调用次数;测试确认它仍返回原值和原错误。
  3. 写一个会等取消通知的测试工具,确认 Run 返回前它已退出。
  4. 分别说明哪些变量属于 Runner、Run 和单个 Future。

每次只改一项,并在测试中确认相应行为。遇到新要求时,先沿现有的所有权和调用路径找位置,再决定是否需要增加新的类型。