PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
FOUNDATIONS / 06

并发、等待与取消

goroutine、channel、可见性和任务收尾。

本章目录

本章从大一刚接触操作系统和 Go 的位置开始。先记住一句话:并发是让多个任务都处于进行中,异步是先交出任务再稍后取结果,取消是请求任务主动停下;它们都不等于强制杀掉一个函数。 Pysolate Spine 的 whole-program PLM 和 prefix 使用这组概念提前启动 Host 工具,但仍保持 Python 控制流和每次 Run 的边界。

源码入口:future.go、bridge.go、prefix.go、runner.go。已实现的 PLM 路径见whole-program-plm.md。Go 基础可交叉阅读 go-language.md、go-values.md;调用栈和 ABI 可交叉阅读 execution-stack.md、memory-abi.md。本章不展开 PLM 的控制流和依赖分析,相关理由见下一章 PLM 思考基础。

1. 串行、并发、并行

想象三件工具任务:查价格、查运费、生成报告。如果必须查完价格,再查运费,最后生成报告,任务按一个顺序逐个完成,这叫串行(serial):同一时刻只有一个工作步骤在推进。

如果价格和运费都已经开始,报告仍要等两者结果,两个读取任务就同时处于进行中。这叫并发(concurrency):系统管理多个相互交错的任务,不要求它们在同一瞬间执行。

如果机器有两个可用 CPU 核心,两个任务在同一时刻分别被两个核心执行,这叫并行(parallelism):多个工作在物理上同时运行。单核机器也可以有并发,操作系统或 Go runtime 让任务轮流运行;有多核时,并发任务才可能真正并行。

这三个词关注的层次不同:串行说有没有重叠,并发说有没有多个进行中的任务,并行说是否真的同时占用多个执行资源。Go 的 goroutine 让代码容易表达并发,但不保证某两个 goroutine 必然并行,也不保证启动顺序。

普通 Runner.Run 中,Guest 在工具调用处同步调用 invoke,当前调用路径等工具返回。PLM 的 prepare 则启动 worker goroutine 后立即给 Guest 一个整数 handle;多个独立读取因此可以重叠。resolve 仍在原来的 Python 调用位置交付结果,所以“工具提前开始”不等于“Python 控制流被改成乱序”。whole-program-plm.md 的价格和运费例子展示了这个边界。prepared full-copy 只改变新 Guest 怎样得到私有初始化内存,不是另一套并发协议。

2. 同步、异步与 blocking

例如收银员接过你的订单后站在柜台前,直到饮料做好才处理下一位顾客,这是**同步(synchronous)**调用:调用者发出请求,并等待这次请求完成后再继续。普通工具调用就是这种形状。

如果收银员收下订单、给你一个取餐号码,然后继续接待别人,这是**异步(asynchronous)**任务:发起者先拿到一个表示“稍后可领取”的东西,工作在别处继续,结果之后再领取。PLM 的 handle 对应这一层意思,但它不是一个脱离生命周期的后台任务。

等待本身叫阻塞(blocking):当前执行路径暂时不能继续。例如 resolve 在 select 中等待 f.done,runState.close 在 workers.Wait() 中等待 worker 退出,RunPrefix 在接收下一个源码片段时等待 chunks。阻塞不表示程序停止了所有工作;等待的 goroutine 可以被 Go runtime 挂起,其他 goroutine 继续运行。

异步也不保证并行。一个异步任务可能排队后才运行,也可能在同一个 CPU 上和别的任务交错。反过来,多个并行计算也可以被一个同步接口包住,让调用者一直等到全部完成。

goroutine 到底是什么

下面的代码让一个任务在后台执行,但没有把它永久绑定到某一条操作系统线程:

// 代码片段:goroutine 只表示 Go 的并发任务,不是固定 OS 线程。
go func() {
    value, err := tool(ctx, args)
    _ = value
    _ = err
}()

goroutine 是 Go runtime 调度的执行单元;这里的调度就是安排哪个任务现在获得执行机会。它通常比直接为每项工作创建 OS 线程更轻量,但本课不据此给出具体成本数字。runtime 把许多 goroutine 安排到一个或多个操作系统线程上;一个 goroutine 可能在等待后继续,也可能被调度到不同的线程。不要把“启动 goroutine”理解为“独占一条线程”。go 语句只说明任务被异步启动,不能由此推出执行顺序、线程身份或并行程度。

3. channel:传值,也能只发信号

先看一个完整程序。它使用一个 channel 传工作项,另一个 channel 传结果;producer 关闭工作 channel,worker 读到关闭后关闭结果 channel,主函数再读完结果。程序没有用 sleep 猜测调度顺序。

程序一:用 channel 传值并用 close 表示结束

将下面内容保存为 channel-close.go,在任意 Go module 外运行 go run channel-close.go。它只使用标准库。

package main

import "fmt"

func main() {
	jobs := make(chan int)
	results := make(chan int)

	go func() {
		defer close(results)
		for n := range jobs {
			results <- n * n
		}
	}()

	go func() {
		defer close(jobs)
		for _, n := range []int{1, 2, 3} {
			jobs <- n
		}
	}()

	for result := range results {
		fmt.Println(result)
	}
}

预期输出:

1
4
9

具体地说,jobs <- n 是发送,n := <-jobs 是接收,close(jobs) 表示以后不会再发送工作项。for n := range jobs 会读到所有已发送的值,然后在 channel 关闭且值读完时结束。worker 关闭 results 后,主函数的 range 才知道结果流已经结束。

channel 是 goroutine 之间传递值或同步信号的管道。chan int 是传整数的 channel 类型,make(chan int) 创建无缓冲 channel,make(chan int, 2) 创建可暂存两个值的 channel。<-chan string 表示这个参数只允许接收字符串,不能通过它发送。

传入 slice、map 或指针时,channel 传递的是这个值,不会自动深复制它背后的全部可变数据;别把“经过 channel”当成“已经独占副本”。chan struct{} 中的 struct{} 是没有字段的空结构体,常用来表示不需要业务载荷的信号。无缓冲 channel 的发送通常要等接收方同时准备好;有缓冲 channel 可以暂存有限个值,缓冲满时发送仍会阻塞。关闭 channel 不会发送一个普通值,而是声明“不会再有值”。接收方可以用第二个结果判断关闭:value, ok := <-ch,其中 ok == false 表示 channel 已关闭且没有剩余值。

通常由发送方负责关闭,因为它最清楚自己是否还会发送。向已关闭的 channel 发送或重复关闭会 panic;从已关闭 channel 读取不会等待,但会得到剩余值或类型的零值。关闭也可以只作完成通知,像本项目的 done chan struct{} 一样,不携带业务结果。

这里的 channel 不是 Future。channel 可以传很多个值、可以有多个发送者或接收者(但关闭所有权必须设计清楚);Future 表示某一个已登记任务及其一次结果。在 Spine 中,Future 结构体自己保存 request、response、cancel,done channel 只负责告诉领取方“response 已经写好”。

select 怎样选择等待条件

select 用于等待多个 channel 操作:有一个可进行就执行对应 case;多个都可进行时,不保证优先选写在前面的那个。如果都不能进行且没有 default,就等待;写了 default 则可以立即走默认分支。nil channel 的发送/接收不能完成,因此放在 select 里不会让那个分支就绪。

本项目在“Future 已完成”和“本轮已取消”之间等待。二者同时就绪时,不能仅凭 case 的文本顺序宣称结果或取消必定优先。close(channel) 也不等于把变量设成 nil。

4. Future、发布可见性与为什么这里不用锁 response

先看 future.go 的实际顺序:prepare 创建 Future,workers.Add(1) 后启动 goroutine;worker 调工具、把值或错误编码到 f.response,然后执行 close(f.done)。resolve 先从本轮 map 取出并删除 Future,再等待 f.done 或本轮 context 被取消。

一个Future 可以先理解成“带编号的待领取结果”。在本项目里它不是一个特殊的 Go 原语,而是普通 struct 加上 handle map 和完成信号:

// 代码片段:future.go 中的结构关系(省略实现细节)。
type future struct {
    request  callRequest
    response []byte
    done     chan struct{}
    cancel   context.CancelFunc
}

Guest 发起 prepare 后拿到的是 handle,不是结果;resolve 用 handle 和当前 request 领取。handle 未知、已被消费或参数不匹配时,代码不拿旧 response 冒充当前调用;匹配时才等待完成并返回原工具的成功结果或错误。工具失败不会因为 resolve 自动重试。

为什么 worker 写 f.response 时不需要互斥锁?因为这里有两个不同的对象:

  1. s.futures map 只由当前 Run 的 Guest 回调路径读写。worker 不访问这个 map;prepare 和 resolve 在同一条同步的 Host 回调路径上发生。map 没有被多个 worker 同时读写。
  2. 一个 Future 的 response 由它自己的 worker 写一次。Guest 路径只有在 <-f.done 返回后才读它。worker 先写 response,再关闭 done;关闭后被接收方观察到,读 response 才发生。

第二点使用了 happens-before(先行发生关系)。具体例子是:写入 f.response 发生在 close(f.done) 之前,而接收方从已关闭的 done 收到信号之后才读取 f.response。Go 的内存模型把这种 channel 同步当作顺序关系,因此这个写入对后续读取可见。这叫发布可见性:worker 把结果发布出去时,不只通知“完成”,还让之前写入的结果成为接收方可以安全观察到的状态。

这不是“并发读写都不用锁”的规则。若两个 goroutine 访问同一份共享内存,其中至少一个在写,并且没有适当同步排序,就可能形成数据竞态(data race);不要求它们恰好在同一纳秒执行。没有写入者时,并发读取 map 是另一回事,不要把所有并发读取都叫竞态。Go 的 race detector 是动态检查工具,只能检查这次实际执行到的路径,可能报告竞态;一次通过不证明所有可能交错都安全。此时必须使用互斥锁或其他同步方式。plm_test.go 和 prefix_test.go 中的 counts/calls map 被多个工具调用共享,所以测试用 sync.Mutex 保护计数的读改写。

互斥锁(mutex) 可以先看作只有一把钥匙的房间:拿到钥匙的 goroutine 进入,其他 goroutine 等待。被锁保护的那段代码叫临界区(critical section)。例如 counts[key]++ 不是一个不可分割动作,它包含读旧值、加一、写回;多个 goroutine 同时做会互相覆盖。把它放在 mu.Lock() 与 mu.Unlock() 之间,才能让这次读改写一次只发生一份。

Future 的 response 使用的是“单写者 + done channel + 之后读取”的发布协议;测试计数 map 使用的是“多个调用者 + mutex”的共享状态协议。两者不能混为一谈。WaitGroup 也不是 mutex:它只记录任务是否结束,不保护 map 或 slice 的内容。

5. WaitGroup:任务从哪里来,在哪里结束

在 runState 中,workers sync.WaitGroup 属于一次 Run。每次成功 prepare,代码先 Add(1),然后才启动 goroutine;worker 用 defer s.workers.Done() 保证正常返回、工具报错或取消返回时都会减少计数。runState.close 先取消本轮 context,再 Wait。

这个顺序回答了两个生命周期问题。第一,Add(1) 必须发生在 worker 可能结束、且 Wait 可能开始之前,否则等待者可能误以为没有任务。第二,Done 必须覆盖所有退出路径,否则 Wait 会永远等下去。当前 Run 的 Guest 执行结束、出错或被取消后,都会经过 state.close;已领取、未领取、失配后取消的 worker 都属于这一轮,都会被等待。

WaitGroup 是任务计数器和等待点,不是任务本身,也不是取消机制。Wait() 只能等任务结束,不能让不合作的任务结束。Spine 把 Future 放在 runState 里而不是全局变量,因此 handle 只属于当前 Run。一个 Run 里的 handle 1 不能在下一次 Run 领取;Future map 每轮新建,测试也覆盖了“future does not cross run”。单次 resolve 会先删除 map 项,重复领取得到错误。

任务所属 Run 还有一个实际含义:未被 Python 领取的提前读取可能已经在运行,但 close 会取消并等待它;Run 返回时不会留下仍在后台运行的、配合取消的 worker。若把 worker 放到全局队列或让它持有下一轮 Guest 的数据,就会破坏这个边界并造成泄漏或串状态。这只覆盖本轮登记的 worker,不会自动收尾调用者创建的任意 goroutine、源码 producer,或工具另外偷偷启动的后台工作;各自的拥有者仍要负责它们。Runner.Close 也有自己的所有权约定:调用者要在所有 Run 返回后再关闭 Runner。

6. context 取消、deadline 与 timeout

第二个完整程序先让 worker 发出“我已准备好”的信号,主函数收到后调用 cancel。worker 没有被杀掉,而是主动选择 ctx.Done() 分支,因此输出顺序可确定。

程序二:协作式取消

将下面内容保存为 context-cancel.go,运行 go run context-cancel.go。它只使用标准库,也没有用 sleep。

package main

import (
	"context"
	"fmt"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	ready := make(chan struct{})
	done := make(chan struct{})
	result := make(chan string)

	go func() {
		close(ready)
		select {
		case result <- "finished":
			fmt.Println("worker: finished")
		case <-ctx.Done():
			fmt.Println("worker: cancelled")
		}
		close(done)
	}()

	<-ready
	cancel()
	<-done
	fmt.Println("main: run ended")
}

预期输出:

worker: cancelled
main: run ended

ready 只让主函数知道 worker 执行到了 close(ready),不保证它已经进入下一行 select;done 让主函数等到 worker 真正退出。它们是两个确定性的握手点,不是根据“通常 worker 会比主函数慢”来猜。result 没有接收者,所以发送分支不会先完成;取消后 worker 能观察到 ctx.Done() 并结束。

context.Background() 提供一个没有 deadline、不会自行取消的根 context。WithCancel(parent) 派生出可取消的子 context,返回它和 cancel 函数;父级取消也会传播到相应子级。Done() 返回的 channel 关闭表示应停止,Err() 用来询问取消原因。

context 取消 是协作式协议:调用方通过 cancel() 发出请求,被调用函数必须检查 ctx.Done()、检查 ctx.Err(),或把 context 传给支持取消的下层 API。取消不是 runtime 从函数栈外部拔掉电源。一个正在执行的 Go 函数可能持有锁、修改一半的数据结构、占用文件或网络资源;任意强杀会破坏这些不变量,因此 Go 不提供安全的“杀死不合作 goroutine”操作。

Spine 的每次 Run 先用 context.WithCancel(ctx) 建立本轮 context;每个 Future 再从它派生自己的 child context。关闭 Run 时调用本轮 cancel,worker 的 Tool(ctx, args) 应该响应。Wasm Guest 也使用这条 context,wazero.NewRuntimeConfig().WithCloseOnContextDone(true) 能让 Guest 执行响应取消;这仍不等于能强杀一个忽略 context 的 Host Go 工具。如果工具卡在不检查 context 的函数里,workers.Wait() 只能继续等它,Run 可能无法及时返回。

deadline 是一个绝对时间点,例如“在 12:00:05 之前完成”;timeout 是一段时长,例如“从现在起 5 秒”。context.WithDeadline 接收时间点,context.WithTimeout 接收时长,后者内部也会形成一个 deadline。到达 deadline 后,Done() 会关闭,Err() 通常返回 context.DeadlineExceeded;主动调用 cancel 则通常返回 context.Canceled。期限到了仍需要被调用函数检查并合作退出。

Context 还怎样找到“本轮状态”?

context 不只是取消信号,也能附带少量请求范围的值。当前 run 使用 context.WithValue(state.ctx, runKey{}, state),把本轮 state 指针放进去;跨过 Wasm 调回 Host 时,再用 ctx.Value(runKey{}).(*runState) 取出。

runKey{} 是一个专用空结构体类型的值,作为 key 可以避免与别人随手使用的字符串键撞名。同一类 key 能取回这条 context 链上的对应值;它不是全局 Run ID。取回值需要类型断言,因为 Value 的返回类型没有保留具体的 runState 类型。

这不会复制整个 state,也不会自动替它建立安全证明。它只让受控的内部回调路径找到正确的拥有者;生命周期仍由 Run 创建和关闭。不要把 context 当作任意全局配置仓库,也不要把它和 Python 的 scope 字典或聊天上下文混淆。

7. 为什么测试用 channel 握手而不是 sleep

sleep(10*time.Millisecond) 只能表示“我猜调度在这段时间内完成了”。机器负载、runtime 调度和 CI 环境变化后,时间猜测可能过早,或者让测试无意义地变慢。它不能证明第二个任务已经启动,也不能证明 worker 已经退出。

Spine 的测试把“已经发生”变成明确事件:

  • plm_test.go 中,first 工具等到 secondStarted 被关闭;second 工具开始时关闭这个 channel。若 PLM 仍串行等待 first 返回,second 根本不会启动,测试会卡住或被 context 取消。这个测试验证重叠启动,而不是比较耗时。
  • 错误测试让第二个提前任务直接返回错误,再检查 Python 能在原调用位置处理它;错误不会在 prepare 阶段越过控制流提前抛出。
  • prefix_test.go 中,源码 producer 先发送第一段,等真实 Host 工具通过 started 握手后再发送剩余片段,测试了接收源码期间任务确实启动。
  • 取消测试让 wait 工具明确等待 <-ctx.Done(),退出前关闭 stopped;测试在 RunPrefix 返回后检查 stopped 已经收到信号。它验证“返回前已回收 worker”,而不是等待一个猜测的时间。

channel 握手的重点是因果顺序:发送或关闭信号就是事件已经发生的证据,接收就是等待这个事件。它比观察日志顺序或猜测速度更接近要验证的语义。

8. 把整条生命周期串起来

一次启用 PLM 的 Run 可以按下面顺序阅读:

  1. run 新建本轮 runState,派生 context 和 Future map。
  2. Guest 的 AST 转换把符合条件的独立读取变成 prepare,Host 为每个读取建立 Future,Add(1) 后启动 worker。
  3. Python 继续按原控制流执行。到原工具调用位置时,resolve 删除一次性 handle,检查工具名和参数是否仍匹配。
  4. 匹配时等待 done;worker 先写已编码的 response,再关闭 done,所以领取方能安全读结果。失配时取消旧工作并在当前位置普通调用。
  5. Guest 正常结束、发生错误或 context 取消后,runState.close 取消本轮剩余任务并 Wait 全部 worker,然后 Run 才返回。

普通 Run 不创建提前 Future;不在显式 earlyReads allowlist 中的工具也在原位置同步执行。RunPrefix 额外从 chunks channel 接收 append-only 源码,关闭 chunks 表示源码输入完成;它复用同一个 runState/Future 机制。源码 channel 的关闭与 Future 的 done 关闭都是同步信号,但一个表示“不会再有源码片段”,另一个表示“这一个结果已经发布”,含义不同。

读并发代码时可以先问四个问题:谁拥有这份状态?谁能写它?完成信号是什么?取消后谁负责等待退出?在 Spine 中,答案分别是当前 Run、对应 worker 或 Guest 回调、done/channel close、runState.close 的 cancel 加 WaitGroup。把这四个答案写清楚,比单独记住 go、chan 或 sync 的语法更重要。