正常
Lab 2.5: Concurrency Debugging Lab
目标
前面的 Lab 我们一直在练习"怎么把并发代码写对"。这个 Lab 反过来——我们面对的是已经写好的代码,但这些代码里有 bug。你的任务不是从零实现,而是用工具和思维方法定位问题、理解根因、修复并回归验证。
这个 Lab 提供 5 个"故意写坏的并发程序",每个程序包含一个已知类型的并发缺陷——data race、丢失唤醒、死锁、use-after-free、false sharing 性能陷阱。你需要在不看答案的前提下,走完"定位 → 假设 → 验证 → 修复"的完整调试循环。代码结构复用 Lab 0–2 中你已经熟悉的模式(线程池、队列、原子计数器),所以理解成本很低,你可以把全部注意力放在调试过程本身。
前置知识
在开始之前,确保你已经读完以下章节:
- ch08-01:并发程序调试技巧 — TSan、helgrind、自定义日志
- ch08-02:并发性能测试与基准 — perf、性能分析方法
- Lab 0–2:理解
JoiningThread、BoundedBlockingQueue、SpscRingBuffer的基本结构
环境准备
这个 Lab 的核心是工具链的配置。你需要以下工具:
- TSan:GCC/Clang 编译时加
-fsanitize=thread -g - helgrind:Valgrind 的一部分,
valgrind --tool=helgrind ./program - perf:Linux 性能分析工具,
perf stat和perf record
安装 Valgrind(如果尚未安装):
展开代码 (共 63 行)收起代码
bash
# Ubuntu/Debian
sudo apt install valgrind linux-perf
# WSL2
sudo apt install valgrind
# perf 可能需要额外步骤,参见 WSL2 文档
```cpp
## 调试方法论
在开始逐个 Bug 程序之前,先建立一套统一的调试流程。每次遇到并发问题,按以下步骤推进:
**第一步:确认是否真的是并发问题。** 用单线程运行同样的逻辑,如果结果正确,那确实是并发引入的缺陷。
**第二步:缩小复现范围。** 减少线程数、数据量、运行轮次,找到最小复现路径。越小的复现代码越容易定位。
**第三步:选择工具。** data race 用 TSan,死锁用 helgrind 或 `valgrind --tool=drd`,性能异常用 `perf stat`。
**第四步:解读报告。** TSan 报告中的 "previous write" 和 "current read" 代表什么?helgrind 报告中的锁序图怎么读?
**第五步:修复后回归。** 不只是"跑通一次",而是在 TSan 下运行完整测试套件,确认问题彻底消失。
## Bug 1: Data Race on Shared Counter
### 症状
多线程无锁修改同一个 `int` 计数器,最终结果不等于预期值。运行多次,每次的结果都不一样。
### 有缺陷的代码
```cpp
// bug1_data_race.cpp
#include <thread>
#include <vector>
#include <iostream>
int counter = 0; // 注意:非 atomic 的 int
void increment(int times)
{
for (int i = 0; i < times; ++i) {
++counter; // 多线程同时修改 → data race
}
}
int main()
{
const int kThreads = 8;
const int kTimes = 1000000;
std::vector<std::thread> threads;
for (int i = 0; i < kThreads; ++i) {
threads.emplace_back(increment, kTimes);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Expected: " << kThreads * kTimes << "\n";
std::cout << "Actual: " << counter << "\n";
return 0;
}调试任务
- 运行程序,记录实际输出与预期值的差距
- 用 TSan 运行,解读报告中指出的 data race 位置
- 修复代码(用
std::atomic<int>替换int) - 选择合适的 memory order(提示:纯计数用
relaxed即可) - 用 TSan 回归验证
验证
修复后,连续运行 10 次,结果都应该等于 kThreads * kTimes(8,000,000)。TSan 不应再有任何报告。
Bug 2: Lost Wakeup
症状
生产者调用 notify_one() 时消费者还没进入 wait(),导致消费者永久阻塞。程序挂住不动。
有缺陷的代码
展开代码 (共 96 行)收起代码
cpp
// bug2_lost_wakeup.cpp
#include <mutex>
#include <condition_variable>
#include <thread>
#include <iostream>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void consumer()
{
std::unique_lock<std::mutex> lock(mtx);
// Bug: 没有 predicate 的 wait
// 如果 notify 在 wait 之前就发生了,wait 会永远阻塞
cv.wait(lock);
std::cout << "Consumer: got the signal\n";
}
void producer()
{
// notify 发生在 consumer 进入 wait 之前
std::lock_guard<std::mutex> lock(mtx);
ready = true;
cv.notify_one();
std::cout << "Producer: sent signal\n";
}
int main()
{
// 这个调度顺序可能触发 lost wakeup
std::thread p(producer);
std::thread c(consumer);
p.join();
c.join(); // 可能永远阻塞
return 0;
}
```cpp
### 调试任务
1. 运行程序多次,观察是否每次都挂住(取决于线程调度)
2. 用超时日志辅助诊断:给 `wait` 加上 `wait_for` 超时,超时后打印日志
3. 修复代码:把 `cv.wait(lock)` 改成 `cv.wait(lock, [&]{ return ready; })`
4. 解释为什么谓词等待能同时解决虚假唤醒和丢失唤醒
### 验证
修复后程序应该在 1 秒内正常退出。尝试不同的线程启动顺序,确认都能正确运行。
## Bug 3: Deadlock from Lock Ordering
### 症状
两个线程以相反顺序获取两把 mutex,特定调度下产生死锁。程序挂住。
### 有缺陷的代码
```cpp
// bug3_deadlock.cpp
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mutex_a;
std::mutex mutex_b;
void task1()
{
std::lock_guard<std::mutex> lock_a(mutex_a); // 先锁 A
std::cout << "Task1: locked A\n";
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lock_b(mutex_b); // 再锁 B
std::cout << "Task1: locked B\n";
}
void task2()
{
std::lock_guard<std::mutex> lock_b(mutex_b); // 先锁 B
std::cout << "Task2: locked B\n";
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lock_a(mutex_a); // 再锁 A
std::cout << "Task2: locked A\n";
}
int main()
{
std::thread t1(task1);
std::thread t2(task2);
t1.join();
t2.join();
return 0;
}调试任务
- 运行程序多次,观察是否偶尔挂住(死锁需要特定的调度顺序)
- 用 helgrind 运行:
valgrind --tool=helgrind ./bug3_deadlock,解读锁序冲突报告 - 用
std::scoped_lock(mutex_a, mutex_b)同时获取两把锁,消除锁序问题 - 或者统一两个线程的加锁顺序(都先 A 后 B)
验证
修复后连续运行 100 次都不应挂住。helgrind 不应再报告锁序冲突。
Bug 4: Use-After-Free in Detached Thread
症状
线程 detach 后继续访问已被销毁的局部变量。程序可能崩溃,也可能输出垃圾值——行为完全取决于调度时机。
有缺陷的代码
展开代码 (共 89 行)收起代码
cpp
// bug4_use_after_free.cpp
#include <thread>
#include <string>
#include <iostream>
void start_background_task()
{
std::string message = "Hello from background";
std::thread t([&message]() {
// Bug: detach 后,message 可能已经被销毁
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout << message << "\n"; // use-after-free!
});
t.detach();
// 函数返回,message 被销毁
}
int main()
{
start_background_task();
// 主线程退出,detached 线程可能还在访问已销毁的 message
std::this_thread::sleep_for(std::chrono::milliseconds(200));
return 0;
}
```cpp
### 调试任务
1. 运行程序多次——有时正常输出,有时输出乱码,有时段错误
2. 用 TSan 运行,查看 use-after-free 的报告(TSan 能检测到对已释放内存的访问)
3. 修复代码:用值捕获代替引用捕获 `[message]() { ... }`,让线程拥有自己的一份副本
4. 或者用 `JoiningThread` 替代 detach,确保线程在变量销毁之前完成
### 验证
修复后连续运行 50 次都应正常输出 "Hello from background"。TSan 不应再有报告。
## Bug 5: False Sharing Performance Trap
### 症状
两个线程各自修改相邻内存位置的 atomic 变量,性能远低于预期。功能完全正确,但吞吐量比单线程版本还低。
### 有缺陷的代码
```cpp
// bug5_false_sharing.cpp
#include <atomic>
#include <thread>
#include <iostream>
#include <chrono>
struct Counters {
std::atomic<int> a{0}; // 两个 atomic 紧挨着
std::atomic<int> b{0}; // 可能在同一个 cache line
};
int main()
{
Counters counters;
const int kIterations = 50000000;
auto start = std::chrono::steady_clock::now();
std::thread t1([&]() {
for (int i = 0; i < kIterations; ++i) {
counters.a.fetch_add(1, std::memory_order_relaxed);
}
});
std::thread t2([&]() {
for (int i = 0; i < kIterations; ++i) {
counters.b.fetch_add(1, std::memory_order_relaxed);
}
});
t1.join();
t2.join();
auto elapsed = std::chrono::steady_clock::now() - start;
auto ms = std::chrono::duration_cast<
std::chrono::milliseconds>(elapsed).count();
std::cout << "Time: " << ms << " ms\n";
std::cout << "a = " << counters.a.load() << "\n";
std::cout << "b = " << counters.b.load() << "\n";
return 0;
}调试任务
- 先运行这个版本,记录耗时
- 修复:给两个 atomic 之间加 cache line padding(
alignas(64)或手动 padding) - 用
perf stat观察修复前后的 cache miss 数量变化 - 对比修复前后的耗时,计算加速比
修复后的结构应该类似:
cpp
struct Counters {
alignas(64) std::atomic<int> a{0};
alignas(64) std::atomic<int> b{0};
};验证
修复后的耗时应该比修复前快 2-5 倍(取决于 CPU 架构)。用 perf stat 观察 cache-misses 指标应该显著下降。
自查清单
- [ ] 5 个 Bug 程序全部定位并修复
- [ ] 每个修复都能说出"为什么原来的代码在特定时序下会出问题"
- [ ] 能区分 TSan 和 helgrind 各自擅长检测的缺陷类型
- [ ] Bug 1:能解释为什么非 atomic 的
++counter在多线程下是 UB - [ ] Bug 2:能解释谓词等待如何同时解决虚假唤醒和丢失唤醒
- [ ] Bug 3:能画出死锁的资源分配图(循环等待)
- [ ] Bug 4:能解释 detach + 引用捕获的生命周期风险
- [ ] Bug 5:能用 perf 数据说明 cache miss 的变化,而不只是"加 padding 后变快了"
- [ ] 所有修复后的代码在 TSan 下无报告