第13章 用 race detector 找到真实共享访问
竞争报告描述的是访问关系
数据竞争指向的是不同 goroutine 对同一内存的未同步冲突访问。它不等同于业务上的所有竞态。两个经过锁保护的扣款步骤仍可能重复执行同一订单;一个没有数据竞争的 channel 协议仍可能死锁。race detector 的价值在于把实际执行到的冲突访问及其创建栈报告出来,让我们从猜测转向具体共享对象。
运行 go test -race ./... 后,报告通常包含发生冲突的读写位置,以及相关 goroutine 从哪里启动。阅读时先确认共享的是哪个对象,再看两个生命周期为何重叠,最后决定应该复制、转移还是同步。只盯着报错行添加一把锁,可能漏掉同一对象的其他访问路径。
随书工程把任务统计放在原子变量中,把接纳与关闭关系放在同一互斥锁下。两种机制分别服务于独立计数和跨字段状态关系。测试覆盖提交者、worker 与关闭者的并发执行,也覆盖请求处理中的共享状态。race 通过说明这些实际路径未发现冲突,不代表任何未来扩展都自动安全。
把报告转成所有权问题
假设处理函数把一个 map 交给后台任务,随后中间件又向同一个 map 添加字段。两个访问即使都“发生在请求附近”,也可能位于不同 goroutine。最简单的修复可能是接纳前复制必要字段,让后台任务拥有独立数据;如果必须共享,则需要统一锁,而不是只保护其中一处写。
类似问题常见于复用字节缓冲区、修改共享 Client 配置、按请求更改全局 logger 属性,以及把一个闭包捕获的变量当作每次调用独立状态。现代 Go 已经改进部分循环变量语义,但不能据此认为闭包捕获都不会竞争。对象引用、外层变量和底层数组仍需逐项分析。
修复时要考虑读者。字段由哪把锁保护、对象什么时候变为不可变、谁可以归还对象池,最好体现在类型和函数边界里。过度使用原子字段可能让代码通过 race,却很难证明字段之间一致。同步方式应服务于完整不变量,而非仅让工具安静。
构造能触发的路径
race detector 需要路径实际执行。若测试总是顺序提交再关闭,它不会覆盖接纳竞争;若外呼测试从不取消读取,它不会覆盖取消回调与完成处理的重叠。测试应根据生命周期边界安排交错,用闩锁和通知明确让两个阶段同时存在,再解除阻塞验证终结。
不要靠缩短 Sleep 强行扩大竞争概率。可以先让两个执行者都到达屏障,然后一起继续;可以重复关闭与提交;可以让客户端在响应头之后取消,以确保读取路径真正启动。这样的测试即使在 race 之外也有行为价值,而不是为了工具专门拼凑访问。
重复运行对竞争窗口有帮助。随书故障组在 race 下重复三十轮,信号退出组重复十轮。次数不是正确性证明,只是提供比单次执行更广的调度观察。若重复测试稳定失败,应优先保留最小重现,而不是继续增加等待时长直到它偶然通过。
race 插桩会改变运行成本
race 构建通常更慢、占用更多内存,也可能改变调度。不能把 race 下的吞吐量当作普通二进制容量。超时测试要区分协议截止与外部测试总截止,给调度留余量,同时仍然验证操作最终结束。为了让 CI 通过把所有截止改成一分钟,会掩盖本应发现的及时性问题。
同样,race 没有报告不意味着在所有平台都安全。未执行路径、外部系统以及某些不在检测范围内的交互仍需要单独分析。对于本书的标准库 Go 工程,它提供强有力的动态检查,但我们仍通过计数守恒、连接恢复和进程退出验证工具不能覆盖的语义。
若报告出现在测试辅助代码,也应认真修复。共享错误列表、未经同步的完成标志、handler 内修改主测试变量都可能让测试结果不可信。测试的同步复杂度应尽量低,错误通过有容量的通道回到主流程,清理通过明确的 defer 安排。
练习:修复一种真实共享数据
在独立练习分支中,让提交任务引用一个请求侧会继续修改的 map,构造同步屏障使访问重叠,观察 race 报告。随后分别用复制与锁修复,比较哪种方案更清楚表达任务所有权。不要把故意竞争代码混入主实验默认测试,以免读者误以为书中交付本身不能通过验收。
为修复增加行为断言:任务看到的是接纳时的值,还是调用者后来更新的值?这两种语义都可能合法,但必须选定。最终提交应同时包含消除数据竞争和明确结果语义的证据。只删掉并发执行让测试不再报错,不算修复了原来的服务需求。
本章实测证据
这是最终完整race命令中的实际输出。通过只覆盖执行到的共享访问;关闭协议还依靠测试内的行为断言。 以下摘自最终本机 go test -race -v ./...,完整命令及环境见附录B;括号中的耗时仅是当次运行记录。
--- PASS: TestPoolConcurrentCloseAndSubmit (0.00s)