concurrency-debugging
并发调试
用途
引导诊断和修复并发 bug:解读 ThreadSanitizer(TSan)竞争报告、使用 Helgrind 进行锁顺序分析、使用 GDB 线程检查检测死锁、识别常见的 std::atomic 误用模式,以及在 C++ 和 Rust 中应用 happens-before 推理。
触发场景
- "ThreadSanitizer 报告了数据竞争——如何解读报告?"
- "程序死锁了——如何调试?"
- "如何使用 Helgrind 查找线程 bug?"
- "我是否正确使用了 std::atomic?"
- "C++ 内存顺序中的 happens-before 是如何工作的?"
- "如何在 GDB 中找出哪些线程发生了死锁?"
工作流程
1. ThreadSanitizer(TSan)——竞争检测
# 使用 TSan 构建
clang -fsanitize=thread -g -O1 -o prog main.c
# 或使用 GCC
gcc -fsanitize=thread -g -O1 -o prog main.c
# 运行(TSan 在运行时拦截内存访问)
./prog
# TSan 特定选项
TSAN_OPTIONS="halt_on_error=1:second_deadlock_stack=1" ./prog
解读 TSan 报告:
WARNING: ThreadSanitizer: data race (pid=12345)
Write of size 4 at 0x7f1234 by thread T2:
#0 increment /src/counter.c:8:5 ← T2 中的访问点
#1 worker_thread /src/counter.c:22:3
Previous read of size 4 at 0x7f1234 by thread T1:
#0 read_counter /src/counter.c:3:14 ← T1 中的冲突访问
#1 main /src/counter.c:30:5
Thread T2 created at:
#0 pthread_create .../tsan_interceptors.cpp
#1 main /src/counter.c:28:3
SUMMARY: ThreadSanitizer: data race /src/counter.c:8:5 in increment
解读方法:
- 第 1 行:访问类型(写/读)和地址
- "Write of size" 下的调用栈:执行写操作的线程
- "Previous read/write" 下的调用栈:冲突的线程
- "Thread T2 created at":线程在哪里被创建
- 修复:
increment和read_counter函数在没有同步的情况下访问同一地址
常见竞争及修复方法:
| 竞争模式 | 修复 |
|---|---|
| 无锁读写全局变量 | 添加互斥锁或使用 std::atomic |
无 atomic 的双重检查锁定 |
使用 std::once_flag + std::call_once |
对共享整数执行 += |
使用 std::atomic<int>::fetch_add() |
| 迭代时修改容器 | 锁定整个临界区 |
shared_ptr 引用计数竞争 |
引用计数已安全(是原子的);但指向的对象可能不安全 |
2. Helgrind——锁顺序与竞争检测
Helgrind 使用 Valgrind 基础设施检测锁顺序违规(潜在死锁)和数据竞争:
# 使用 Helgrind 运行
valgrind --tool=helgrind --log-file=helgrind.log ./prog
# 锁顺序违规报告
==1234== Thread #3: lock order "0x... M2" after "0x... M1"
==1234== observed (incorrect) order
==1234== at pthread_mutex_lock (helgrind/...)
==1234== by worker2 /src/worker.c:45 ← T3 先获取 M2 后获取 M1
==1234==
==1234== required order established by acquisition of lock at address 0x... M1
==1234== at pthread_mutex_lock
==1234== by worker1 /src/worker.c:31 ← T1 先获取 M1 后获取 M2
锁顺序违规 = 潜在死锁:
- 线程 T1 获取 M1,然后尝试获取 M2
- 线程 T2 获取 M2,然后尝试获取 M1
- 两者竞争时都可能死锁
修复:强制执行一致的全局锁顺序。始终先获取 M1 再获取 M2。
3. 使用 GDB 检测死锁
# 将 GDB 附加到死锁进程
gdb -p $(pgrep prog)
# 或在 GDB 下运行后触发死锁
(gdb) info threads # 列出所有线程及当前状态
# * 1 Thread 0x... (LWP 1234) "prog" ... in __lll_lock_wait ()
# 2 Thread 0x... (LWP 1235) "prog" ... in __lll_lock_wait ()
# 阻塞在 __lll_lock_wait 的线程 = 等待互斥锁
(gdb) thread 1
(gdb) bt # 显示线程 1 在等待哪个互斥锁
(gdb) thread 2
(gdb) bt # 显示线程 2 持有/等待哪个互斥锁
# 查找互斥锁的持有者
(gdb) p ((pthread_mutex_t*)0x601090)->__data.__owner # Linux glibc 互斥锁
# 打印持有线程的 TID
# Python 脚本转储所有互斥锁持有者(GDB 7+)
python
import gdb
for t in gdb.selected_inferior().threads():
t.switch()
print(f"Thread {t.num}: {gdb.execute('bt 3', to_string=True)}")
end
4. std::atomic 误用模式
// 错误:原子变量,但复合操作不是原子的
std::atomic<int> counter{0};
if (counter == 0) counter = 1; // 不是原子的!TOCTOU 竞争
// 正确:使用 compare_exchange
int expected = 0;
counter.compare_exchange_strong(expected, 1);
// 错误:对同步标志使用 relaxed 顺序
std::atomic<bool> ready{false};
// 生产者:
data = 42;
ready.store(true, std::memory_order_relaxed); // 错误:无 happens-before
// 正确:使用 release-acquire 发布数据
// 生产者:
data = 42;
ready.store(true, std::memory_order_release); // 与 acquire 同步
// 消费者:
if (ready.load(std::memory_order_acquire)) { // 与 release 同步
use(data); // 此处可以安全读取 data
}
// 错误:跨线程使用数据而不加原子/互斥锁
// int shared_data; // 非原子 — 并发访问是未定义行为
// 正确:用互斥锁保护或使用原子类型
std::mutex mtx;
std::unique_lock lock(mtx);
shared_data = 42;
5. happens-before 推理
在 C++ 中,happens-before 通过以下方式建立:
顺序先于(单线程内):
代码中语句 A 在 B 之前 → A happens-before B
同步于(跨线程):
在同一原子变量上 store(release) → load(acquire)
→ store happens-before load
→ store 之前的一切 happens-before load 之后的一切
线程创建/等待:
spawn(T) → T 中的任何操作 (创建与开始同步)
T 中的任何操作 → join(T) (join 与结束同步)
互斥锁:
unlock(M) → lock(M)(下一个获取者)
// 跨线程建立 happens-before
std::atomic<int> flag{0};
int data = 0;
// 线程 1:
data = 42; // A
flag.store(1, memory_order_release); // B:A 顺序先于 B
// 线程 2:
while (flag.load(memory_order_acquire) != 1) {} // C:与 B 同步
int x = data; // D:C 顺序先于 D
// D 读取 42:A happens-before B 同步于 C 顺序先于 D
// → A happens-before D
6. Rust 并发——编译期保证
Rust 通过所有权在编译期阻止数据竞争:
use std::sync::{Arc, Mutex};
use std::thread;
// 共享可变状态:Arc<Mutex<T>>
let counter = Arc::new(Mutex::new(0u32));
let c = Arc::clone(&counter);
let t = thread::spawn(move || {
let mut val = c.lock().unwrap();
*val += 1;
});
t.join().unwrap();
println!("{}", *counter.lock().unwrap());
// Rust 阻止:
// - 跨线程共享 &mut T(Sync 未为 &mut T 实现)
// - 将非 Send 类型移入线程(编译器错误)
// 如需 TSan 检查,可与 cargo test 配合使用:
// RUSTFLAGS="-Z sanitizer=thread" cargo +nightly test
相关技能
- 使用
skills/runtimes/sanitizers了解 TSan 构建标志和其他 Sanitizer - 使用
skills/profilers/valgrind了解 Helgrind 和 Memcheck 集成 - 使用
skills/debuggers/gdb进行高级 GDB 线程检查 - 使用
skills/low-level-programming/memory-model了解 C++/Rust 内存顺序理论
More from killvxk/low-level-dev-skills-zh
binutils
GNU binutils 二进制操作与分析技能。适用场景:使用 ar 管理静态库、使用 strip 或 objcopy 处理二进制文件、使用 addr2line 将地址转换为源码位置、使用 strings 提取文本、或使用 c++filt 对 C++ 名称进行反混淆。触发条件:涉及 ar、strip、objcopy、addr2line、strings、c++filt、ranlib 或二进制后处理任务的查询。
1ebpf
Linux 可观测性和网络的 eBPF 技能。适用场景:使用 libbpf 或 bpftrace 编写 eBPF 程序、挂载 kprobe/tracepoint/XDP 钩子、调试验证器错误、使用 eBPF map,或实现跨内核版本的 CO-RE 可移植性。触发条件:查询 eBPF、bpftool、bpftrace、XDP 程序、libbpf、验证器错误、eBPF map 或使用 BPF 进行内核追踪相关问题。
1clang
C/C++ 项目的 Clang/LLVM 编译器技能。适用场景:使用 clang 或 clang++ 进行诊断、sanitizer 插桩、优化备注、通过 clang-tidy 进行静态分析、通过 lld 实现 LTO,或从 GCC 迁移到 Clang。触发条件:涉及 clang 标志、clang-tidy、clang-format、更好的错误信息、Apple/FreeBSD 工具链或 LLVM 特定优化的查询。涵盖标志选择、诊断调优及与 LLVM 工具的集成。
1gcc
C/C++ 项目的 GCC 编译器技能。适用场景:选择优化级别、警告标志、调试构建、LTO、sanitizer 插桩,或诊断 GCC 编译错误。涵盖调试与发布构建的标志选择、ABI 问题、预处理器宏、配置引导优化(PGO)及与构建系统的集成。触发条件:涉及 gcc 标志、编译错误、性能调优、警告抑制或跨标准编译的查询。
1cmake
C/C++ 项目的 CMake 构建系统技能。适用场景:编写或重构 CMakeLists.txt、配置源外构建、选择生成器(Ninja、Make、VS)、使用 target_link_libraries 管理目标和依赖、通过 find_package 或 FetchContent 集成外部包、启用 Sanitizer、为交叉编译配置工具链文件,或导出 CMake 包。触发条件:涉及 CMakeLists.txt、cmake 配置错误、目标属性、安装规则、CPack 或 CMake Presets 的查询。
1cpp-modules
现代 C++ 项目的 C++20 模块技能。适用场景:使用命名模块、模块分区、头文件单元、CMake MODULE_SOURCES、Clang -fmodules-ts、BMI 缓存问题,或从头文件迁移到模块。触发条件:涉及 C++20 模块、import 语句、模块接口单元、头文件单元或 BMI 文件的查询。
1