程序在测试里偶发崩溃,或者多线程下的结果不稳定。这时候用 sanitizer 构建来复现是很自然的想法。但把 AddressSanitizer 和 ThreadSanitizer 一起打开会直接构建失败,即使勉强打开,得到的报告也常常互相干扰。
原因不是工具实现不完善,而是两者对内存的假设根本冲突。理解这一点,才能决定在哪个阶段用哪个工具。
两个工具的假设互斥
ASan 通过影子内存标记每一段地址是否可访问,在每次内存访问上插入检查。它需要精确控制地址空间布局和分配行为。
TSan 的做法是记录每个内存位置的访问历史与同步顺序,用它判断两个线程的访问之间是否存在边。它也需要对内存布局和运行时行为有很强的假设。
两者都要接管内存分配、拦截运行时库、并在编译期插入检查代码。这些接管方式互相冲突,因此不能在同一份二进制里共存。构建系统会直接拒绝这种组合,这不是可以绕过的限制。
按问题类型选择工具
选工具的第一步是明确要查什么。
偶发崩溃、栈被破坏、疑似释放后使用、内存越界,这些指向地址问题,应该用 ASan。多线程结果不稳定、加锁顺序相关、数据被并发读写,这些指向竞争问题,应该用 TSan。
在实践中,顺序通常是先用 ASan 把内存问题清理掉,再用 TSan 查竞争。因为内存越界会破坏 TSan 自己维护的元数据,让竞争报告充满噪声。反过来先修竞争再修内存问题,收益也不高。
构建方式是在编译和链接阶段都加上对应选项:
1 | cmake -DCMAKE_BUILD_TYPE=Debug \ |
-g 与不省略帧指针这两项很关键。没有它们,报告里的调用栈会退化成地址,定位代价远高于构建代价。
报告要按证据顺序读
一次崩溃报告通常包含错误类型、访问地址、以及分配和释放的调用栈。阅读顺序建议是先看错误类型,再看访问地址属于哪一类区域,最后看栈。
几个常见误读。第一,报告里第一次出现的栈不一定是出错位置,可能是分配位置。第二,new/delete 的栈只说明这块内存曾经属于谁,不说明当前访问者是谁。第三,多线程程序里,报告中的线程编号需要和业务线程对应起来才有意义。
需要稳定的复现输入。如果同一份输入下报告时有时无,可能是竞争而非越界,这时候换用 TSan 更合适,而不是继续调整 ASan 的采样。
用未定义行为检查补充
UBSan 检查的是另一类问题,并且可以和 ASan 同时使用。它捕捉的是有符号溢出、错位访问、类型违例这类标准未定义的操作。这些问题可能多年不触发,换个编译器或优化等级就变成真实故障。
1 | cmake -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" .. |
在 CI 里跑 sanitizer 构建时,需要注意它只在被测代码真正执行到相关路径时才报告。没有覆盖到的分支不会得到保护,因此 sanitizer 的结果应该和测试覆盖率一起看,而不是当作完整证明。
与发布构建的边界
sanitizer 构建改变了内存布局、分配策略和运行时行为。它能发现的问题是真实存在的,但不能据此推断发布构建下的性能特征,也不能保证发布构建中不存在同类问题——只是那些路径在测试里没有被执行到。
因此这类构建适合放在持续集成和专项排查里,不适合直接作为交付产物。
参考资料
证据边界
本文讨论 sanitizer 的适用场景与构建方式,不包含任何具体项目的检测结果。工具报告只能说明被执行的路径上存在或未发现某类问题;未覆盖的分支、外部依赖内部以及硬件相关行为,都需要各自的检测手段与证据。