20 — Core 深度复查实录:并发缺陷的八个模式¶
2026-07-26 对
core/{engine,context,fsm,permission}全部非测试源码做了一次逐行 复查,确认了约 20 项缺陷,两轮修复后go test -race ./...全量通过。 这篇笔记不罗列修复清单(见 CHANGELOG [Unreleased]),而是提炼缺陷背后可复用的 模式——每一条都值得在写新代码和评审时当 checklist 用。
背景与方法¶
复查方式是最朴素也最有效的:逐文件逐行读非测试源码,对每个可疑点做三步验证——
- 找齐所有调用方(含测试),确认触发路径真实存在;
- 用最小复现程序验证机制成立(如 COW 就地排序问题用 20 行 stdlib 程序演示 "旧视图被新状态的排序改写");
- 写回归测试锁住修复(能串行验证的绝不依赖竞态时序)。
值得一提:这些缺陷全部通过了当时的测试套件。测试绿≠正确——绿的只是被测的路径。
模式一:COW 的隐形契约——"复制"分三个等级¶
v[:len(v):len(v)] 这种容量封顶复制在 COW 代码里非常常见,但它只提供
append 安全(append 必然重分配),不提供改写安全(底层数组仍共享)。
本次最严重的缺陷就是对这种"复制"出来的切片就地 sort.Slice,
把已发布旧状态里正被无锁读取的数组原地打乱。
三个等级要分清,并在代码里显式标注:
| 等级 | 写法 | 允许的操作 |
|---|---|---|
| 共享引用 | dst[k] = v |
只读 |
| 容量封顶 | dst[k] = v[:len(v):len(v)] |
只读 + append(触发重分配) |
| 完整拷贝 | append([]T(nil), v...) |
任意(sort/swap/原位改写) |
检查法:任何 sort.Slice/copy/下标赋值出现前,先回答"这个底层数组归谁"。
详细分析见 01-cow-engine.md 的 V5 一节。
模式二:快路径缓存失效,要按"持有者集合"逐一通知¶
引擎中间件链有两级缓存:组合链(gen 比对)+ 编译链(compiledVersion 快路径)。
快路径只比对版本号、不比对 gen——这是性能设计,但它把失效的正确性完全押在
"变更时会通知到每一个持有者"上。
Use/UseForGroup 只遍历了 state.matchers,而临时 matcher 活在 TempManager
里——它们的版本号无人递增,旧中间件链在快路径上永久生效。插件热重载换上的
守卫中间件对活跃会话形同虚设。
教训:引入"版本比对快路径"时,同步维护一份"持有者集合"清单写进失效函数的
注释里;新增一种持有者(本例是 TempManager)时全文检索所有失效点。修复即
tempManager.ForEach 补全通知(连带发现 ResetMiddlewares 从来没通知过任何人)。
模式三:锁契约是签名的一部分¶
ensureContainerInitialized 从"须在持有 Manager 锁时调用"重构为"自加锁的两段式"
(锁内收集、锁外注册),但一个仍按旧契约持锁调用它的测试没有同步更新——
不可重入的 Mutex 直接自死锁,-race 全量跑挂满 5 分钟超时。
教训:
- 改锁契约 = 改签名。同一次提交里必须更新全部调用方(含测试)与 doc comment;
- 命名承载契约:xxxLocked 后缀(调用方持锁)与自锁版本分开命名,
本仓库的 collectContainerBackfillLocked/applyContainerBackfill 是正例;
- 复查时对每把锁画"谁持有时调用了谁",StartTempMatcherCleaner 的无锁写共享
字段问题也是这么找出来的。
模式四:哨兵值会吃掉合法极值¶
六路归并迭代器用 bestPrio := math.MaxUint 作"尚无候选"的哨兵、严格小于比较——
于是优先级恰为 MaxUint 的匹配器被判为不存在,且提前终止整个归并(其后所有
列表被跳过)。同族问题:getPriority 把 uint64 转 uint,32 位平台截断高位。
教训:状态就该用状态表示(bestIdx == -1、found bool),不要挪用值域里的
一个合法值;跨类型转换在边界值(0、MaxXxx、负数)上各过一遍。
模式五:规则必须是纯函数——副作用延迟提交¶
OnCooldown 在规则命中的瞬间写入冷却时间戳。但"这条规则通过"不等于"这个
matcher 命中":排在它后面的规则失败、或者真正处理事件的是另一个 matcher,
用户的冷却都被白白扣掉。讽刺的是同文件的 And() 注释自己写着"规则应该是纯函数"。
修复不是把警告写得更大声,而是给框架加机制:Context.DeferRuleEffect 登记副作用,
引擎在该 matcher 全部规则通过后统一 Commit,任一规则失败则 Discard。
规则本身回归纯函数,顺序不再影响正确性。
教训:文档约束挡不住结构性问题。当"正确用法"需要使用者时刻小心时, 应该把小心固化成机制。
模式六:双数据源要定一致性协议¶
TempManager 有两份数据:分片(权威)+ RCU 快照(视图)。快照从"每次写全量重建" 优化为增量维护后,立刻出现两类交错缺陷:并发 Remove 与 Add 交错产生幽灵条目 (快照里有、分片里无,永远匹配);增量插入与全量重建并发产生重复条目 (同一事件双重执行)。
协议三条: 1. 先改权威(shard,持 shard 锁),后改视图(snapshot,持 snapMu); 2. 视图更新时回查权威(snapMu 内查 byID,已被删则放弃插入); 3. 幂等化(插入前指针去重,删除不存在时空操作)。
锁序固定为 snapMu → shard.RLock,与全量重建一致,杜绝倒序死锁。
模式七:没有测试守护的配置项等于不存在¶
WithSharedExecPool 被 NewEngine 无条件覆盖——选项从诞生起就不生效,零测试覆盖,
静默至今。WithPendingDeleteProcessInterval 的自定义间隔被写死的常量忽略。
批量删除处理器整套机制(通道、goroutine、三个配置项)在生产代码里没有任何
发送方——文档描述的是死代码。
教训:每个 WithXxx 选项至少一条"设置后行为确实改变"的测试;
发现死代码时二选一(恢复或删除),挂着不动的死代码会让文档持续说谎,
还白养一个每 100ms 空转的 goroutine。
模式八:行为修复的涟漪要用全量 -race 收口¶
v1.21.1 的两个正确修复(熔断器阈值钳制、命令 handler 经 ExecPool 异步化)
让五个散布在 middleware/resilience、router、tests 的旧测试失效——它们
依赖的是修复前的语义(用超高阈值阻止熔断器闭合、Dispatch 后同步断言)。
教训:
- 异步化之后,测试必须有显式同步点(WaitForAsyncHandlers),否定断言
不等待的话会假通过;
- 语义收紧后,测试要换成新语义下仍然可能的场景来测原目标
(槽位耗尽改用"在途未完成的探测"占位,反而测得更准);
- 行为修复的验收标准是全仓库 -race 全绿,不是本包全绿。
一段并行开发的插曲¶
复查进行中,仓库同时被另一个工具修改(plugin/ 十余个文件)。两个教训:
- 修改实现的一方必须同步测试与文档——模式三的死锁正是"实现改了、测试没跟"的 直接后果;
- 基于快照工作的一方(本次的我)在写回每个文件前必须核对 mtime, 发现漂移就以现行版本为基底重新套用修改,绝不整文件覆盖。
收束¶
这次复查没有发现任何"高深"的缺陷——全部是契约漂移:COW 的复制等级、缓存的
持有者集合、锁的调用约定、哨兵的值域、规则的纯度、双数据源的主从、选项与
实现的绑定、测试与语义的同步。并发代码的正确性不在技巧里,在契约的显式化
和被守护程度里。 把契约写进类型(xxxLocked)、写进断言(回归测试)、
写进注释(持有者清单),下一次漂移才会在编译期或 CI 里被拦住,而不是在
凌晨的死锁堆栈里。
相关笔记¶
01-cow-engine.md— COW 契约与 V5 修复细节02-six-way-merge-matcher.md— 归并迭代器与 TempManager 快照演进08-command-system.md— 命令元数据缓存一致性与索引分类不变式12-fsm-engine.md— FSM 会话并发模型