Skip to content

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:理解 JoiningThreadBoundedBlockingQueueSpscRingBuffer 的基本结构

环境准备

这个 Lab 的核心是工具链的配置。你需要以下工具:

  • TSan:GCC/Clang 编译时加 -fsanitize=thread -g
  • helgrind:Valgrind 的一部分,valgrind --tool=helgrind ./program
  • perf:Linux 性能分析工具,perf statperf 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;
}

调试任务

  1. 运行程序,记录实际输出与预期值的差距
  2. 用 TSan 运行,解读报告中指出的 data race 位置
  3. 修复代码(用 std::atomic<int> 替换 int
  4. 选择合适的 memory order(提示:纯计数用 relaxed 即可)
  5. 用 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;
}

调试任务

  1. 运行程序多次,观察是否偶尔挂住(死锁需要特定的调度顺序)
  2. 用 helgrind 运行:valgrind --tool=helgrind ./bug3_deadlock,解读锁序冲突报告
  3. std::scoped_lock(mutex_a, mutex_b) 同时获取两把锁,消除锁序问题
  4. 或者统一两个线程的加锁顺序(都先 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;
}

调试任务

  1. 先运行这个版本,记录耗时
  2. 修复:给两个 atomic 之间加 cache line padding(alignas(64) 或手动 padding)
  3. perf stat 观察修复前后的 cache miss 数量变化
  4. 对比修复前后的耗时,计算加速比

修复后的结构应该类似:

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 下无报告

v0.7.0-9-g940ec1b · 940ec1b · 2026-07-05