05.09 并发验证:从发送与关闭交错到资源退出证据
返回第五卷 · 取消前置:05.03 · 内存模型前置:05.04 · 组合前置:05.08 · 测试基本方法:10.02
05.09 并发验证:从发送与关闭交错到资源退出证据
DeepTutor 初稿经技术与教学审阅后的静态课程。队列、goroutine、交错与测试代码均为纸上设计;没有运行 Go、
go test -race、网络服务、故障注入或站点。本章教学习者以后怎样构造可复核证据,不能把纸上预期写成已通过的测试结果。
一、测试“发送时恰好关闭”,到底要证明什么?
虚构 IM 的 u-a 准备把 m-a 交给本进程内容量 1 的队列,另一任务正要求停止发送并关闭资源。05.03 已讲 channel 关闭与 Context 取消,05.04 区分数据竞争和业务竞争,05.08 又要求每项工作明确“已接纳/处理中/未接纳”。现在需要检查四种不同结论:有没有无同步读写、会不会向已关闭 channel 发送而 panic、取消前后 m-a 被不被接纳、所有启动的 goroutine 是否确实退出。其中任何一项通过,都不能自动替其他项背书。
| 第一遍 | 第二遍 | 交付物 |
|---|---|---|
预期状态、可控门闩、两种 select 交错 | race detector、唯一关闭者、退出/超时和故障注入 | T1/T2 时序表、关闭状态机、验证矩阵 |
本章仍是静态教材。未来学习者在自己的隔离工程里可将这些预期变成测试,并保留 Go 版本、输入、命令和实际结果;本项目此次没有执行课程中的任何并发例子。
二、用“到达门闩—释放门闩—退出确认”替代 Sleep
让发送者在真正进入 select 前暂停:它先通知测试方“已到关键点”(ready),再等测试方释放(release);执行结束后通知 done。测试方等到 ready 后,才安排取消、队列状态或关闭动作,最后释放发送者并等 done。这些通信事件建立可检查的先后;time.Sleep(10*time.Millisecond) 只赌某台机器的调度速度,既不保证发送者到达了关键点,也不能证明后续结果。
发送者:到达 select 前 → 通知 ready → 等 release → 进入 select → 报告分支 → 通知 done
测试方:等 ready → 安排队列/取消状态 → 释放 release → 限时等 done → 检查结果done 很重要:cancel() 只是发出停止信号,Go 的 CancelFunc 不等待工作真的结束。若测试只看到 cancel 返回就结束,阻塞在 channel 发送、网络读取或下游结果汇聚的 goroutine 可能继续留着。等待本身必须有有限上限,超时要报告停在哪个门闩/队列及哪位所有者未关闭;不能让负例测试永远挂起。这里的上限是测试保护,不是产品的延迟 SLO。
三、容量 1 的 T1/T2:取消先发生,也可能仍接纳消息
固定发送者携 m-a,使用纸上逻辑 select { case queue <- m-a: 已接纳; case <-ctx.Done(): 未接纳 }。在发送者等待 release 时,测试方先调用 cancel,再释放它。结果取决于此刻发送分支是否也能立即进行:
| 变式 | 进入 select 前的队列与取消 | 哪些分支可进行 | 可验证的结果 |
|---|---|---|---|
| T1 | 容量 1 队列已有 m-b,且消费者在本轮被门闩暂停;Context 已取消 | 发送无空位;取消已就绪 | 发送者走取消,m-a 未接纳 |
| T2 | 队列空,Context 已取消 | 发送可立即入队;取消也已就绪 | Go select 可选任一分支;m-a 可能入队,也可能未接纳 |
T1 的“消费者暂停”是必要假设:若它在发送者决策前取走 m-b,发送也会变可用,结论就不同。T2 则是容易误判的规则:Go 规范规定多个通信同时可进行时选择其中一个,不能写“因为先调用 cancel,所以发送分支必定不执行”。如果业务确实要求取消确认后绝不再接纳,应定义统一的接纳所有者或锁内状态转换,并在取消调用方得到“已停止接纳”的确认后才宣称关闭生效;一个含两个 ready 分支的 select 本身不能提供该合同。
也不要为避免 T2 不确定性而直接关闭 queue:如果发送者仍可能执行发送分支,向已关闭 channel 发送会 panic。取消信号、生产者退出确认与最终 close 是三步,不可颠倒。
四、Open→Stopping→Closed:唯一关闭者和每一步的负例
把共享发送路径画成三个业务状态:Open 接受新工作;Stopping 不再接受新工作、通知生产者退出并处理已接纳项;Closed 所有发送者已结束,唯一协调者关闭 channel/连接。此图是需要实现的协议,而不是 close(queue) 一句自动完成的状态机。
Open ──停止新接纳/发布取消──→ Stopping
Stopping ──等待全部生产者 done──→ 唯一协调者 close(queue) → Closed值得设计的失败变式有:接收者擅自关闭仍有发送者使用的 channel,后来出现 send-on-closed panic;两个协调者都认为自己负责 close,重复关闭会 panic;某发送者对取消不响应,协调者等待永不结束;消费者停止后仍有已接纳的 m-b 没有处理或记录去向。close 表示“不再有新发送”,并不会删除缓冲中的值。若要求排空,在 Stopping 中定义谁消费到何时;若要求放弃,明确哪些项尚未产生副作用、哪些结果未知。
关闭连接或 Context 也不自动撤回已经写进内核发送缓冲的字节。并发验证要检查业务状态与资源状态同时闭合,不能只断言一个 panic 没出现。
五、race detector 与业务竞争:两种测试问不同问题
Go 官方 race detector 可在未来学习者的代码中通过 go test -race 等方式检测实际执行到的无同步同址访问。它能帮助定位 05.04 那种普通 Online 字段被并发读写的 data race;若本次输入没有走到冲突路径,即使存在缺陷也可能没有报告。因此“某次 -race 绿了”不是所有调度均安全的证明。检测还会改变执行速度与资源占用,不能把它的耗时直接当生产性能。
即使没有 data race,业务也可能错:两个任务分别在一段锁内检查 message_id=m-a 不存在,解锁后各自再进入另一段锁并插入。每次 map 访问都受保护,竞态检测器未必报问题,但两者都可能先读到“不存在”,造成重复受理。应使用门闩固定这个业务交错,并检查最终唯一性;修复要覆盖整个“检查→插入”不变量,或用数据库唯一约束/事务。race detector 和业务预期断言是互补证据,不能互相代替。
| 检查对象 | 典型工具/纸上方法 | 没有证明什么 |
|---|---|---|
| 未同步读写 | race detector 与 happens-before 审查 | 所有业务顺序、幂等 |
| 指定业务交错 | 门闩、最终状态/结果断言 | 未覆盖的所有调度 |
| 资源退出 | done/等待组与有限超时 | 网络对端或持久存储确认 |
六、泄漏验证:结果返回以后,谁还在等谁?
05.08 的流水线有经典负例:结果消费者提前退出,上游 goroutine 还阻塞在向已无人读取的输出 channel 发送。它不会因为“请求函数返回”就自动退出;它持有的栈和引用也可能使内存继续保留。纸上检查表要列每个启动者、退出条件、等待位置、负责取消的人和 done 证据。
只看 runtime.NumGoroutine() 在测试前后是否恰好相等很脆弱:测试框架与运行时可能有背景任务,数字变化也不告诉你哪一个业务任务未退出。更有针对性的验收是让本次测试自己启动的任务各自发出完成信号,协调者在有限期限内逐个等到;超时记录残留的 send/receive/lock/network 等待位置,再结合未来的 goroutine profile。不能通过无限增大 Sleep 让测试“看起来不泄漏”。
错误路径同样要关心退出:队列满时生产者收到取消后能否结束?worker 存储失败后,fan-in 是否仍等永远不会来的第六个结果?重复 close 后 panic 会不会让其他清理逻辑跳过?每个纸上变式都应有一个明确的有限最终状态。
七、故障注入与证据等级:假对象能验证到哪里
借 10.04 已讲的可控依赖,未来学习者可让假发送器返回“只写入 2 字节”、假存储在某步报错、消费者停在门闩、队列保持满或 Context 先取消。这些可控故障适合验证状态机和错误传播;它们不能证明真实网络的 TCP 确认、真实数据库提交或掉电恢复。集成环境与真实负载证据另行取得,不在本章直接运行。
| 层级 | 能验证 | 留下的限制 |
|---|---|---|
| 静态纸上时序 | 所需状态、合法交错、预期与反例 | 未执行代码 |
| 受控假对象/门闩测试 | 指定交错下的分支和收尾 | 只覆盖安排的路径与假语义 |
| race detector | 已执行路径上的数据竞争报告 | 无报告不是全路径证明 |
| 真实 I/O/数据库测试 | 特定环境下的接口与失败结果 | 仍非所有网络、掉电和跨节点条件 |
记录应包含 Go 版本、输入/队列初态、受控顺序、期望、实际结果、超时上限和仍未覆盖的交错。课程内容是“未来应收集什么证据”,不是声明这些命令已在本仓库通过。
八、交付并发验证矩阵与分层练习
交付 T1/T2 的队列与 select 就绪表、Open→Stopping→Closed 的唯一关闭/等待责任图、一个普通字段 data race 和一个无 data race 的业务重复反例,再为每个 goroutine 写“谁启动、谁取消、谁等完成”。表中结果是纸上预期,学习者真实运行后应在个人实践记录里另附命令与版本。
分层练习:先答,再展开反馈
1. 本章要分别验收哪两类“竞争”?
共享内存数据竞争与业务检查/变更交错,两者不是同一问题。
2. `ready` 门闩证明什么?
发送者已到测试指定关键点;不能只靠睡眠猜测。
3. `release` 在本章起什么作用?
由测试方在安排好取消/队列状态后,明确放行发送者进入 select。
4. `done` 为什么不能由 `cancel()` 替代?
取消仅发停止信号,不等待工作结束;done 才给退出确认。
5. T1 队列里已有谁,容量多少?
容量 1,已存 m-b,且消费者在发送者决策前保持暂停。
6. T1 先 cancel 再 release,`m-a` 会被接纳吗?
在固定前提下不会:队列仍满,发送不可进行,只能走已就绪的取消分支。
7. T1 若消费者提前取走 m-b,上一题结论还稳吗?
不稳。发送也可能变就绪,需要重新分析。
8. T2 队列空且 Context 已取消,哪个分支就绪?
发送和取消两个分支都就绪。
9. T2 能断言 m-a 绝不入队吗?
不能。Go select 可选择任一可进行分支。
10. 严格“取消确认后不再接纳”需要什么?
需要串行接纳所有者或锁内状态协议,且取消调用方等待停止接纳确认。
11. 取消后立刻由接收者关闭 queue 安全吗?
不安全。仍有发送者可选择发送分支,可能 send-on-closed panic。
12. 谁应负责最终关闭多生产者共享的输出 channel?
唯一协调者在确认所有生产者结束后关闭。
13. `close(queue)` 会丢弃缓冲中的 m-b 吗?
不会。缓冲值仍可被接收,处理或放弃由业务合同决定。
14. `go test -race` 无报告能证明所有路径安全吗?
不能。它只检测实际执行到的竞态。
15. 用 `Sleep` 让发送“先于”关闭能给语言同步保证吗?
不能。睡眠不建立所需的同步边,也不能控制调度。
16. 两个锁区间分别检查不存在、再插入,可能重复吗?
可能。两者都可先读到不存在,虽无数据竞争仍破坏业务不变量。
17. race detector 会替测试检查唯一消息 ID 规则吗?
不会。业务结果需要单独的断言与受控交错。
18. 下游退出而上游还向无人读的 channel 发送,可能怎样?
上游 goroutine 长期阻塞并保留资源;取消与退出要可观察。
19. 测试一直等不到 done,应无限等待还是设上限?
设有限上限,超时报告具体任务/等待位置;上限不是产品时延指标。
20. `runtime.NumGoroutine()` 测试前后相等就充分证明无泄漏吗?
不充分。背景任务和任务身份不同,优先等本测试启动的任务各自退出。
21. 假发送器返回短写能证明真实设备收到消息吗?
不能。它只验证应用对这个可控返回值的处理。
22. 纸上预期可写成“本仓库 race 检测已通过”吗?
不能。本章没有运行任何 Go 或并发检测,实际证据由学习者后来记录。
来源与下一步
- Go race detector 官方说明与Go 内存模型:动态检测与 happens-before 的范围。
- Go 语言规范:
select、context与testing:双就绪分支、取消不等待与测试生命周期。 - Go Pipeline 与取消教程:下游提前退出导致上游阻塞的反例与停止协议。
按学习路线,下一章05.10 性能工具与解释学习 CPU/heap/mutex/block profile 与执行 trace 如何解释真实的等待和分配证据。离开本章前,应能用明确门闩、有限等待和最终状态复核一次发送/取消/关闭交错,并指出 race、业务结果与资源退出各需要哪份独立证据。