并发竞态为什么难测,MAccConc 给出的工程化思路

Project Zero 的并发测试文章在 Hacker News 上有 9 points,热度不高,技术密度却很高。它介绍的 MAccConc 工具通过记录内存访问、识别可能互相影响的访问点,再用栈追踪和延迟注入控制线程顺序,探索原本很难稳定重现的竞态条件。

竞态的难点在哪里

两个线程各自运行都可能正确,只有某个非常窄的交错顺序会触发 use-after-free、死锁或状态错乱。普通压力测试让线程多跑几次,能提高偶然撞上的机会,却很难说明“为什么这次触发”和“怎样稳定再现”。修复后,回归测试也可能因为时序改变而失效。

这套方法做了三件事

第一,它记录线程访问过的内存位置,找出至少有一次写入且范围重叠的通信点。第二,它用带调用次数的栈追踪稳定标识某次访问,避免只依赖每次都变化的地址。第三,它在指定访问前后设置唤醒和等待动作,强迫线程按某种顺序交错。

这种方法不会凭空证明所有竞态都不存在。它把不可控的时间问题,缩成可以描述、重放和比较的访问顺序。

普通项目也能借鉴什么

  1. 先画出共享状态和所有读写者,标注锁、引用计数和生命周期边界。
  2. 给并发测试记录事件顺序,不只保存最终断言失败。
  3. 优先围绕两个关键线程做最小测试,减少无关调度噪声。
  4. 为已修复的竞态保存一组可重放的顺序约束。
  5. 让回归测试同时验证正确结果、无死锁和失败后的清理。

工具选型的边界

ThreadSanitizer 更适合发现数据竞争,模型检查器适合状态空间较小的协议,压力测试适合发现系统级偶发问题。延迟注入适合已经知道可疑访问点、需要把交错顺序固定下来的场景。把一种工具当成万能探测器,反而会漏掉真正的问题。

写竞态测试时,先把共享对象缩小到最少。每个线程的动作要有明确编号,读写地址、锁状态和释放点都应能在失败日志中对应起来。测试失败后,保存触发顺序和最小输入,下一次先重放这组顺序,再扩大探索范围。

这类工具也有成本。内存访问追踪会增加构建和运行复杂度,调度约束过多会让结果难以阅读。工程上可以只在专项测试、夜间 fuzz 或修复回归中启用,日常单元测试保持轻量。

如果项目暂时没有内核级追踪能力,也可以从事件日志开始。给锁获取、释放、队列入队和对象销毁加上低开销编号,在失败时还原关键顺序。日志不能替代内存访问追踪,却能帮助团队确认问题属于锁竞争、生命周期错误还是业务状态冲突。

FAQ

加 sleep 能测到竞态吗?偶尔可以,但 sleep 不能稳定表达某个内存访问之前或之后发生了什么。

竞态回归测试应该保存什么?保存最小输入、线程角色、关键访问点和顺序约束。

用户态程序能直接照搬吗?思想可以借鉴,具体追踪和调度接口需要根据运行时与平台重新实现。