多线程应用的测试与调试
读完能区分竞态、数据竞争、死锁、活锁与不变量破坏,理解 ThreadSanitizer 的动态覆盖边界,并用契约、可控调度、进展超时、历史校验和独立性能基准构造可复现的多线程测试证据。
学习目标
- 能分类一个诡异的并发现象——给定症状(结果时对时错 / 程序卡死 / CPU 跑满却没进展 / 偶发脏数据),能说出它大概率属于竞态、死锁、活锁、数据竞争、破坏不变量中的哪一类,以及该往哪查
- 能区分死锁与活锁,并用 ThreadSanitizer 分析本次已覆盖的访问、解释零报告的边界;能设计带参考模型、超时、操作历史和可重放种子的结构化并发测试
- 能回答:某段并发代码「跑了一次没出错」,能不能就此认定它是对的?为什么不能?应该怎么测才靠谱?
机制总览
并发缺陷分类与证据组合
- 1
分类症状
无进展先区分阻塞等待、循环重试和单纯缓慢;错误结果再查竞争。
- 2
运行检测
TSan 发现未同步冲突,压力与故障注入扩大稀有交错。
- 3
验证修复
修复后同时检查正确性、不变量、吞吐和尾延迟。
章级决策实验
并发缺陷分类与证据组合
从症状出发区分数据竞争、条件竞争、死锁、活锁和性能退化。
选择推理阶段
当前阶段 · 分类症状
无进展先区分阻塞等待、循环重试和单纯缓慢;错误结果再查竞争。
可核验证据
线程 dump、CPU 使用率与进度计数。
并发测试的目标不是固定一种时序,而是在许多合法时序中持续验证不变量,并保留可复现证据。
失效—证据矩阵
并发缺陷分类与证据组合
分类症状
典型失效
把活锁当死锁,或把条件竞争等同于 data race。
核验证据
线程 dump、CPU 使用率与进度计数。
运行检测
典型失效
加日志改变时序后 bug 消失,就认为已经修复。
核验证据
sanitizer 报告、随机种子与事件 trace。
验证修复
典型失效
用一把大锁消除报错,却让系统失去进展或性能。
核验证据
回归矩阵、锁等待与基准对比。
从“测试通过不等于已证明”开始
整本书你学会了让多个厨师高效地一起干活。可真到了忙时,后厨偶尔会冒出怪事:两个厨师同时改同一张单子,菜出错了;两人各攥着对方要的工具僵在原地,谁也动不了;又或者两人在窄过道里反复对向让路,一起往左、一起往右,谁也过不去。这一章就回答:这些乱子怎么分门别类,又怎么把它们揪出来?
最让人头疼的是:这些乱子时有时无。你忙的时候它犯,你一停下来盯着看,它又乖乖不犯了——你越想抓现行,它越不出现。靠「多跑几次碰运气」根本不可靠。
所以得换思路:先写清什么结果算对、什么进展算活着,再控制关键线程在同一窗口起跑,记录失败种子与操作历史,并用动态检测器补充证据。测试能发现错误,却无法穷尽所有调度;“这次没报错”永远不是正确性证明。
先认脸:并发 bug 分门别类
调试的第一步不是动手改,而是看症状对号入座——不同的并发 bug 长相不同,查法也不同。下面这张速查图把全书出现过的几类摆在一起,每类配「症状 / 常见成因 / 往哪查」,先扫一眼记住它们的脸:
逐个点名(都呼应前面各章,本章把它们统一当「要测试、要调试的 bug 源」):
↡条件竞争(race condition,又称竞态条件):程序的正确性依赖多个线程操作的相对时序,而这个时序又无法保证。表现为结果时对时错、重跑会变。后厨版:两个厨师同时去改同一张单子,谁后写谁覆盖。第 3 章讲过它,本章把它当作要测试调试的一类 bug。: 正确性依赖线程之间的相对时序,而时序不受你控制——结果时对时错、换台机器或加点负载就变样。它常常是更具体问题(如数据竞争、破坏不变量)的统称。
↡多个执行单元形成等待环,环中每个成员都在等待另一个成员才能继续,因此整体无法取得进展。互斥量顺序相反是常见成因,但 future、条件变量、线程池任务依赖也能形成等待环。:
多个执行单元形成等待环,环中每个成员都要等另一个成员先完成。互斥量顺序相反只是一个例子;future
相互等待、线程池任务耗尽也会死锁。相关线程通常阻塞,但进程里其他线程仍可能占用
CPU。
↡执行单元持续改变状态、退避或重试,却因彼此响应而长期没有完成任何高层操作。它与死锁的核心差异是参与者仍在执行,不是 CPU 使用率必然为某个数值。: 线程没被锁住、反而都在忙——检测到冲突就退避重试,可退避动作总同时发生,于是反复礼让、谁也没真正前进。它和死锁是这一章重点要分清的一对,下一节专门对比。
↡两个潜在并发的操作发生冲突,其中至少一个不是原子操作,且二者既不在同一线程形成先后,也没有 happens-before 关系。普通 C++ 程序一旦发生数据竞争就是未定义行为。: 两个潜在并发的操作冲突、至少一个不是原子操作,且没有 happens-before 把它们排出先后——在普通 C++ 程序中这是未定义行为。两个原子操作不会仅因并发访问同一对象而构成数据竞争,但错误的内存序仍可能破坏更高层协议。
↡破坏不变量(broken invariant):一个对象本应始终满足某个「不变量」(如「链表的 size 等于结点数」「这两个字段要么都更新、要么都不更新」)。当一个线程在中途(只更新了一半)时,另一个线程看到了这个半成品的非法状态,或异常打断了更新,就破坏了不变量。后厨版:半成品的菜被当成成品端走。: 对象本该始终满足某条「不变量」,可一个线程更新到一半的「半成品」状态被别的线程看见(或被异常打断),不变量就破了——表现为断言失败、链表断裂这类非法状态。
记住这五张脸,遇到诡异现象就能快速缩小范围。下面把最容易混的死锁与活锁单独拎出来对比。
一对孪生陷阱:死锁 vs 活锁
死锁和活锁都表现为高层操作没有进展。核心区别是:死锁参与者在等待环里无法继续,活锁参与者仍在执行与改变状态却长期不完成操作。下面的最小示意中,死锁线程阻塞、活锁线程忙重试;真实系统还可能有其他工作线程,因此不能只看进程总 CPU 就下结论。
猜一猜:死锁和活锁,哪个 CPU 占用率高?
在这张最小图里,活锁一侧的忙重试会比阻塞等待占用更多 CPU,所以预测答案是活锁更高。但这只是线索:带 sleep 的退避型活锁可能 CPU 很低,局部死锁也可能与其他忙线程并存。现场要结合超时后的全线程栈、锁/等待图、重试计数和已完成操作计数判断。
互斥量死锁可通过统一锁顺序或一次性获取多锁来消除;其他等待环要重构任务依赖、保留可执行容量或禁止池内同步等待。活锁则要保证退避最终有一方获胜,例如指数随机退避、仲裁者或有界重试后的升级路径;随机化降低概率,但不等于形式上的进展保证。
怎么把乱子揪出来:审查、结构化测试、消毒器
定位并发 bug 有三件趁手的工具,从「人看」到「机器自动抓」:
第一是代码审查清单:人脑过一遍——这块共享数据被几个线程碰?都加锁了吗?加锁顺序一致吗?某个函数抛异常时,它该交的结果交了没(不然等它的线程会永远等下去)?审查抓的是「设计层」的疏漏,便宜又有效。
第二是结构化测试:先用顺序参考模型验证业务语义,再显式构造并发窗口。用屏障让线程同时到达关键操作,给每个等待设置截止时间,记录线程数、随机种子、操作与返回历史;失败时保存全线程栈与进展计数。并发容器还应把历史交给顺序模型或线性化检查器验证,不能只看最终总数。
第三是动态检测器,代表是 ↡由编译器插桩和运行库组成的数据竞争检测器,通常用 -fsanitize=thread 启用。它分析本次实际执行且被插桩的内存访问与同步事件;未执行路径、未插桩模块和更高层协议错误可能漏检。。它在本次执行覆盖到冲突访问时,用 ↡若操作 A happens-before 操作 B,则 A 在抽象执行中明确先于 B;线程创建/汇合、互斥量、条件变量和适当的原子内存序可以建立这种关系。它不是墙钟时间比较。 关系判断两次冲突访问是否被同步排出先后:
第 1 / 5 步 · ① 线程 A 写 x、线程 B 读 x,两者之间没有任何同步——没加锁、没用原子、没有 happens-before 边
A 写、B 读、无同步 → 插桩记录本次访问与同步 → 比对 happens-before → 报告已观察到的数据竞争。可暂停、单步、拖进度。
读懂第 ③④ 拍就懂了核心:TSan 把本次观察到的冲突访问与同步事件放进 happens-before 模型;若冲突未被同步排序,就报告数据竞争及两侧栈。它是动态检测,只看执行过的路径;零报告只能说明这次覆盖未发现,不能证明没有竞态、死锁、原子协议错误或未覆盖的数据竞争。典型开销约为 5~15 倍时间、5~10 倍内存,并且通常需要相关代码都被插桩,所以应放在专门测试任务中。
写代码时就为「可测试」着想
最省事的调试,是让 bug 一开始就难以藏身。↡为可测试性设计(design for testability):在写并发代码时就让它好测——核心是尽量减少共享状态、把对并发设施(线程、锁、时钟)的依赖抽成可替换/可注入的接口,并让纯逻辑能脱离线程单独跑、先在单线程下测对。共享越少、依赖越能替换,要测的并发面就越小。有几条朴素原则:
- 减少共享状态:能不共享就不共享。让线程多在自己的数据上独立干活、最后再合并,需要同步的地方就少,能出竞态的地方也就少。
- 把并发依赖抽出来、可注入 / 可替换:别把「开线程」「取当前时间」「调度」这些硬编码进业务逻辑。抽成接口,测试时换成可控的假实现(比如让「时钟」按你的剧本走),就能确定性地复现特定时序。
- 让逻辑能单线程先测对:把「算什么」和「怎么并行地算」拆开。纯逻辑(如「累加器加 1」「容器插入」)应该能脱离线程、在单线程下被穷尽测试。逻辑都对了,并发测试才只需盯「同步」这一层。
- 让等待与进展可观测:阻塞接口接受超时或停止令牌,关键状态有低扰动计数器,测试能判断“尚未完成”“已失败”“已停止”,而不是永久挂住 CI。
这三条的共同主线:缩小要测的并发面。共享越少、依赖越能替换、逻辑越能单独验证,留给并发 bug 的藏身之处就越小。
故意把它逼出来:压力测试与 heisenbug
并发 bug 的偶发性,是测试它最大的敌人。↡观察或诊断手段改变程序的时间与调度,使故障消失、移动或换一种表现的缺陷。日志、断点和消毒器插桩都可能扰动时序,因此失败现场需要低扰动记录与可重放输入。 会在你加 printf、挂调试器或启用插桩后改变表现;TSan 也会扰动时序,只是它能从已执行访问中检测竞争,不能被描述成“不改语义、每次必抓”。
↡在明确资源边界内,用多种线程数、操作混合、持续时间和可记录随机种子反复施加负载,并持续检查安全不变量与进展条件。过度订阅只是一个测试维度,不是越多越好。 用来扩大调度覆盖,而不是盲目“开远超核数线程”。至少覆盖 1、2、接近硬件并行度、适度过度订阅和生产上限;用屏障对齐关键窗口,记录随机种子,设置超时并持续校验不变量。线程太多可能只测到调度器抖动或资源耗尽,反而遮住目标协议。
性能测试:与正确性检测分开
TSan 构建的插桩与内存开销会彻底改变时序和吞吐,不能拿来做性能结论。性能测试应使用接近发布的优化构建,固定硬件、亲和性与数据集,先测单线程基线,再画 1/2/4/... 核的吞吐和尾延迟曲线,同时记录 CPU、上下文切换、内存带宽与锁等待。
性能回归也要区分延迟、吞吐、扩展性和资源上限:平均吞吐提高不代表 P99 延迟更好,8 核加速不代表 64 核仍扩展。每组结果要有预热、多次样本和波动范围;正确性套件先通过,性能套件再回答“值不值得并行”。
上手玩一玩这三张图
本章三张图分别管「分清两种卡住」「看懂工具」「学会分类」,配合 §6 代码一起玩:
- 死锁 vs 活锁 ``(主 Demo):单步看左栏等待环冻结,右栏持续重试却进度归零。CPU 差异只属于这张最小示例;真实现场还要看线程栈、等待图和完成计数。
- TSan 抓数据竞争
<TsanDetectionDiagram />:单步看「A 写 / B 读、无同步 → 插桩记录访问与同步 → 比对 happens-before → 报告冲突两侧」。再指出图的边界:只有本次执行且被插桩的路径可被分析。 - 并发 bug 速查 ``:对照五类 bug 的症状 / 成因 / 往哪查,做 §8 选型题前先把五张脸记牢。
代码对照:有数据竞争的版本 vs 加锁修复版
type D 的代码对照要把「前后两个方案的差异行」摆在一起看。先看有数据竞争的翻车版——四个线程同时对一个无保护的全局变量做读-改-写:
#include <thread>
#include <vector>
int run_unsafe() {
int counter = 0; // 生命周期覆盖所有已 join 的线程,但没有同步保护
std::vector<std::thread> pool;
for (int t = 0; t < 4; ++t) {
pool.emplace_back([&counter] {
for (int i = 0; i < 100000; ++i) ++counter; // 数据竞争:UB
});
}
for (auto& th : pool) th.join();
return counter; // 不能用任何一次观测结果解释这个含 UB 的程序
}++counter 与其他线程的 ++counter 是未排序的冲突访问,直接构成数据竞争与未定义行为。“丢更新”是常见现象,但标准不限制程序只表现为计数偏小;编译器可基于无数据竞争假设做优化,所以不能拿某次输出反推语义。
用 -fsanitize=thread 编译并运行这条路径时,若冲突访问在本次执行中被观察到,TSan 会报告类似:
WARNING: ThreadSanitizer: data race (pid=12345)
Write of size 4 at 0x... by thread T2:
#0 run_unsafe()::$_0::operator()() race.cpp:8
Previous write of size 4 at 0x... by thread T1:
#0 run_unsafe()::$_0::operator()() race.cpp:8
Location is stack of main thread报告会给出冲突访问与线程创建栈,但一次零报告仍不能证明正确。下面用同一局部状态与局部互斥量重写,避免全局测试状态跨用例泄漏:
#include <mutex>
#include <thread>
#include <vector>
int run_safe() {
int counter = 0;
std::mutex mutex;
std::vector<std::thread> pool;
for (int t = 0; t < 4; ++t) {
pool.emplace_back([&] {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mutex);
++counter;
}
});
}
for (auto& th : pool) th.join();
return counter; // join 后读取,预期 400000
}代码对照:结构化测试骨架(先逻辑、再压测)
光修好还不够——要有测试守住它,且测试本身要「结构化」:先单线程测逻辑、再多线程压测。下面是一个可直接编译运行的骨架(被测对象用原子计数器,逻辑简单到一眼能验对):
#include <atomic>
#include <cassert>
#include <thread>
#include <vector>
// 被测对象:一个线程安全计数器(用原子,逻辑很简单)
struct Counter {
std::atomic<int> n{0};
void inc() { n.fetch_add(1, std::memory_order_relaxed); }
int get() const { return n.load(std::memory_order_relaxed); }
};
// ① 先单线程测「逻辑」对不对:不开线程,纯验证 inc/get 的行为
void test_logic_single_thread() {
Counter c;
assert(c.get() == 0);
c.inc();
c.inc();
assert(c.get() == 2); // 逻辑错(如忘了 +1)在这里就该挂,省得去并发里猜
}第一步刻意不开线程:如果连单线程下 inc 两次都不等于 2,那是逻辑写错了,跟并发毫无关系——在这一步揪出来,比在多线程的一团乱麻里猜要省心得多。逻辑过了,第二步才上高压,逼出偶发的并发 bug:
#include <atomic>
#include <cassert>
#include <thread>
#include <vector>
struct Counter { // 同上一段被测对象:线程安全计数器
std::atomic<int> n{0};
void inc() { n.fetch_add(1, std::memory_order_relaxed); }
int get() const { return n.load(std::memory_order_relaxed); }
};
void run_counter_case(int thread_count) {
constexpr int kPerThread = 100000;
Counter c;
std::vector<std::thread> pool;
for (int t = 0; t < thread_count; ++t) {
pool.emplace_back([&c] {
for (int i = 0; i < kPerThread; ++i) c.inc();
});
}
for (auto& th : pool) th.join();
assert(c.get() == thread_count * kPerThread);
}
void test_concurrency_matrix() {
constexpr int cases[] = {1, 2, 4, 8, 16};
for (int thread_count : cases) run_counter_case(thread_count);
}线程数矩阵至少覆盖顺序基线、低并发、接近机器并行度和适度过度订阅;生产上限应由测试配置补入,而不是硬编码“64 一定够压”。这个计数器例子只检查一个最终不变量;测试队列时还要记录每次 push/pop 的调用与返回历史,验证无重复、无丢失以及是否存在合法顺序解释。
容易踩的坑
小结
- 先按契约分类:竞态是时序相关的错误;数据竞争有精确定义且导致 UB;死锁是等待环无进展,活锁是持续执行却无高层进展;不变量破坏还要追到具体协议
- 现场证据不能只看 CPU:设置超时,保存全线程栈、等待图、重试与完成计数;CPU 使用率只能帮助提出假设
- TSan 是动态数据竞争检测器:它分析本次执行且被插桩的访问,零报告不是正确性证明,也不覆盖所有死锁、活锁和原子协议错误
- 并发测试要可判定、可重放:顺序参考模型、屏障、线程数矩阵、随机种子、操作历史、不变量和进展断言共同构成证据链
- 正确性与性能分开测:消毒器构建找缺陷,发布构建测吞吐、尾延迟和扩展曲线;测试通过不能证明所有调度,基准变快也不能证明协议正确
练习
问题 1(症状 → 证据假设) 下面四个现象各支持哪类初步假设?还需要收集什么证据才能确认?
(a) 程序运行中突然完全卡住、不再有任何输出,此时 CPU 占用几乎为 0。
(b) 一个并行求和程序,结果每次跑都不一样,有时对、有时偏小;用 -fsanitize=thread 一编译运行就报 data race。
(c) 程序卡住不出结果,但 CPU 四个核全部跑满 100%,风扇狂转。
(d) 一个并发链表,偶尔遍历到一半崩溃,检查发现某个结点的 next 指向了已被释放的内存,而 size 字段和实际结点数对不上。
问题 2(方法论判断题) 同事写了个无锁队列,本地开 4 个线程跑了 100 次测试全过,就提 PR 说「测过了、没问题」。请指出这个测试的不足,并给出更靠谱的测试方案。
问题 3(分析题) 打开 `` 单步到最后一拍:两者共同点与关键区别是什么?为什么 CPU 占用只能作为线索,还应抓哪些证据?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 条件竞争
race condition。程序正确性依赖多个事件的相对时序,而协议没有保证所需次序。它是高层语义概念,不必然包含 C++ 标准所定义的数据竞争;全原子程序也可能有条件竞争。
- 死锁
deadlock。多个执行单元形成等待环,环中每个成员都依赖另一个成员先完成,因此整体无法取得进展。锁、future、条件变量和任务依赖都可能参与等待环。
- 活锁
livelock。执行单元持续响应彼此、改变状态或重试,却长期没有完成高层操作。参与者仍在执行是核心;进程总 CPU 高低不是定义。
- 数据竞争
data race。两个潜在并发操作发生冲突,至少一个不是原子操作,且没有 happens-before 关系排出先后。普通 C++ 程序发生数据竞争即为未定义行为。
- 破坏不变量
broken invariant。对象跨字段、跨节点或跨阶段必须持续满足的关系被某条并发或异常路径破坏。测试要直接断言该关系,而不是只等待崩溃。
- happens-before
C++ 抽象执行中的先后关系,可由线程内顺序和同步关系传递建立。它用于判断可见性与数据竞争,不是比较两个操作的墙钟时间。
- ThreadSanitizer(TSan)
动态数据竞争检测器,由编译器插桩和运行库组成。它分析本次执行且被插桩的访问与同步事件;未执行路径、未插桩模块及高层协议错误可能漏检,零报告不构成正确性证明。
- 为可测试性设计
design for testability。减少共享状态,把时钟、执行器和等待策略做成可注入依赖,让业务逻辑可由顺序模型验证,并让等待、取消和进展可观测。
- heisenbug
观察手段改变时间与调度,导致故障消失、移动或改变表现的缺陷。日志、断点和消毒器都可能扰动时序,所以要保存低扰动轨迹、输入与随机种子。
- 压力测试
stress testing。在明确资源边界内覆盖多种线程数、操作混合与持续时间,记录随机种子,并持续检查安全不变量和进展条件。过度订阅是一个维度,不是越多越好。