5.2 并发案中案:竞争、死锁与 TSan 取证


5.2 并发案中案:竞争、死锁与 TSan 取证

数据竞争(Data Race)指两个线程无同步地访问同一内存且至少一个是写;死锁(Deadlock)指线程互相持有对方需要的锁,全体僵持。并发缺陷是最接近"完美犯罪"的一类:证据转瞬即逝、加观测即消失、复现概率随负载波动。本节给出这两类案件的作案机理与专用取证仪器。

数据竞争:为什么它是最狡猾的嫌疑人

一段有竞争的代码长什么样:

// 两个线程同时执行 if (counter == MAX) { // 线程 A 和 B 同时读到 counter == MAX-1 reset_cache(); // 两个线程都进来了:函数被执行两次 } counter++; // 丢失更新:两词自增只生效一次

它狡猾在三处:

  1. 窗口极小:出错需要的交错概率可能万分之一,测试跑一百遍都绿;
  2. 后果延迟:破坏发生在这里,崩溃发生在很远的地方(下游读到脏数据),现场与案发地脱节;
  3. 观测即变:加日志、断点、strace 都会改变时序,第 1 章的海森 Bug 主要就是它。

编译器和 CPU 还会"协同作案":指令重排让无同步的读写顺序在别的核心眼里与代码顺序不同。所以竞争的定义里强调"无同步"——只要正确用了锁或原子操作,重排就被禁止,案情就不成立。

死锁:四要素构成的绑架案

死锁成立需要四个条件同时满足:互斥、持有并等待、不可剥夺、循环等待。破案的抓手通常是破坏"循环等待"——全员按固定顺序拿锁,环就构不成。

死锁的取证反而简单,因为现场不会消失:死住的程序永远停在那里。第 5.1 节的口供总录直接定罪:

(gdb) thread apply all bt

典型案卷:

Thread 2: holding lock A, waiting lock B #0 futex_wait ... mutex_lock (lock=B) #1 withdraw (account=...) at transfer.c:44 Thread 5: holding lock B, waiting lock A #0 futex_wait ... mutex_lock (lock=A) #1 deposit (account=...) at transfer.c:61

两个线程的回溯交叉成环,各自持有的锁和等待的锁白纸黑字——死锁案是并发案件里最好破的。

运行时检测还有更主动的手段。pthread_mutex 有错误检查模式(PTHREAD_MUTEX_ERRORCHECK),能查出同线程重复加锁这类自锁;更强的锁分析器能画出等待图并自动找环。

TSan:数据竞争的专用测谎仪

ThreadSanitizer 是编译器内建的插桩工具(-fsanitize=thread),在每次内存访问处插入检测代码,用向量时钟算法(类似 Lamport 时钟)判断两次访问是否构成无同步的冲突。它的可怕之处在于不依赖复现出错结果——交错本身被记录,即使这次没造成损害也报警:

# 构建时开启(重编译,不能事后挂载) clang -fsanitize=thread -g -O1 -o myapp myapp.c ./myapp

报警输出:

WARNING: ThreadSanitizer: data race Write of size 8 at 0x7f... by thread T2: #0 update_stats stats.c:120 Previous read of size 8 at 0x7f... by thread T5: #0 render_dashboard ui.c:88 Location is global 'stats_buffer'

一次读写双方、两份栈、冲突地址全齐——把 stats.c:120 的写加锁或改原子,案子就破。TSan 的代价:内存涨 5–8 倍、速度慢 2–20 倍,所以它属于测试环境仪器:压测流量回放时开着它跑,把交错摊开给检测器看。

并发缺陷的取证策略

并发缺陷的取证策略

⚠️ 常见坑:用 TSan 和 ASan 同时插桩。两个 sanitizer 的运行时互不兼容,会直接崩溃或误报满天飞——一次只开一种。

工程预防:把竞争消灭在案发前

取证的尽头是预防。行之有效的三道防线:

  1. 减少共享:能线程局部就不共享,能消息传递(队列)就不共享内存——共享点少了,竞争面按平方收缩;
  2. 锁纪律:模块文档写明锁的层级顺序,code review 按顺序查;带超时的锁获取(拿不到就放弃并记录)能把死锁降级成可观测错误;
  3. 并发测试常态化:CI 里放一个低配多核容器,开着 TSan 跑并发压测用例。竞争发现越早,修复成本越低——发布后才发现的竞争,平均要一到两周才破。

本节要点回顾

  • 竞争的定义三要素:多线程、同一内存、至少一个写且无同步——缺一不构成案件;
  • 竞争证据会蒸发,TSan 用向量时钟在测试环境主动制造并记录交错;
  • 死锁现场不蒸发,全员回溯交叉成环即定罪,是并发案里最好破的;
  • 锁顺序破坏循环等待,超时获取让死锁降级为可记录错误;
  • TSan 与 ASan 不能同开,一次一种仪器。

延伸:死锁现场的一次性取证

死锁是最"友好"的并发案:现场凝固不动,取证只要把每个线程"拿着什么、等什么"抄下来:

gdb -p $PID -batch \ -ex 'set print thread-events off' \ -ex 'thread apply all bt 8' > deadlock.txt # 人工比对: 线程A 栈中 lock(m1)->lock(m2), 线程B 栈中 lock(m2)->lock(m1) # => 锁序反转, 交叉等待即铁证 # 运行期监测: glibc 内置死锁检测 export LD_PRELOAD=libstdc++... # 或使用 hb_deadlock/ 当场打印持有矩阵

写成报告时附上"锁获取顺序图",工程修复是给全局锁定序(如按地址排序加锁),并把它写进团队规范——死锁的防复发手段只有一条:全系统统一的锁序。

延伸:TSan 报告的结构化阅读

ThreadSanitizer 报告的核心是两栏:一栏写操作(读/写、栈、持有锁),另一栏写先前冲突操作。定性看类型:data race(同一地址并发读写且无同步)是未定义行为的铁证;deadlock 是锁环警告。TSan 的开销约 5–15 倍减速与 5–10 倍内存,只能进测试负载,配合压力并发与随机调度扰动(TSAN_OPTIONS=halt_on_error=0 history_size=7)在 CI 里长期跑,比人肉 review 并发补丁可靠得多。

clang++ -fsanitize=thread -g -O1 race_demo.cpp -o demo TSAN_OPTIONS=second_deadlock_stack=1 ./demo 2>&1 | head -40

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U