20、常见并发陷阱:数据竞争、死锁与优先级反转
并发编程,说白了就是让多个线程一起干活。听起来很美好,但实际写起来,坑一个接一个。我做了十几年C++,踩过的坑能绕公司三圈。今天咱们就聊聊最常见的三个陷阱:数据竞争、死锁,还有优先级反转。
20.1 数据竞争(Data Race)—— 最隐蔽的杀手
数据竞争是什么?简单说:两个或多个线程同时访问同一块内存,至少有一个是写操作,而且没有任何同步机制。结果就是——程序行为完全不可预测。
我遇到过最头疼的一个bug:线上服务每周五下午准时崩溃,查了两个月才发现是数据竞争。两个线程同时更新一个计数器,没有加锁。平时没事,但周五流量高峰,竞争概率大增,就崩了。
20.1.1 典型的数据竞争场景
// 错误示例:数据竞争
int counter = 0;
void thread_func() {
for (int i = 0; i < 100000; ++i) {
++counter; // 这不是原子操作!
}
}
int main() {
std::thread t1(thread_func);
std::thread t2(thread_func);
t1.join();
t2.join();
std::cout << counter << std::endl; // 结果不确定
return 0;
}
你想想看,++counter 在汇编层面其实是三步:读、加、写。两个线程同时执行,就可能出现:t1读了值5,t2也读了值5,各自加1,都写回6。明明加了两次,结果只加了1。
20.1.2 如何检测数据竞争
我个人习惯用这些工具:
- ThreadSanitizer(TSan):Google出的神器,编译时加
-fsanitize=thread,运行时自动检测数据竞争。我几乎每个项目都会开。 - Helgrind:Valgrind套件里的,功能类似,但性能开销大一些。
- 静态分析工具:比如Clang-Tidy,能发现一些明显的竞争模式。
20.1.3 避免数据竞争的方法
- 用互斥锁保护共享数据:最直接的方式,但要注意锁的粒度。
- 用原子操作:对于简单的计数器、标志位,用
std::atomic<>更高效。 - 用无锁数据结构:高手用的,但实现难度大,容易引入新bug。
- 尽量不共享数据:每个线程处理自己的数据,最后再合并。这是最干净的方案。
// 正确示例:用原子操作
std::atomic<int> counter{0};
void thread_func() {
for (int i = 0; i < 100000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}
20.2 死锁——线程们互相掐架
死锁就是两个或多个线程互相等待对方释放资源,结果谁也动不了。就像两个人过独木桥,你让我先过,我让你先过,结果都堵在桥上。
20.2.1 死锁的四个必要条件
| 条件 | 说明 |
|---|---|
| 互斥 | 资源一次只能被一个线程占用 |
| 持有并等待 | 线程持有资源的同时,还在等待其他资源 |
| 不可剥夺 | 资源不能被强制拿走,只能由持有者主动释放 |
| 循环等待 | 存在一个线程-资源的循环链 |
只要破坏其中一个条件,死锁就不会发生。我一般从「循环等待」和「持有并等待」入手。
20.2.2 经典死锁示例
// 死锁示例
std::mutex m1, m2;
void thread1() {
std::lock_guard<std::mutex> lock1(m1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock2(m2); // 等m2
}
void thread2() {
std::lock_guard<std::mutex> lock2(m2);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock1(m1); // 等m1
}
thread1锁了m1等m2,thread2锁了m2等m1。嗯,死锁了。
20.2.3 死锁的检测与避免
检测方法:
- 用
std::lock一次性锁多个互斥量,避免分步加锁。 - 用
std::scoped_lock(C++17),它内部用了死锁避免算法。 - 运行时检测:比如用
std::timed_mutex加超时,超时了就说明可能死锁。
避免策略:
- 固定加锁顺序:所有线程按相同顺序加锁。比如总是先锁m1再锁m2。
- 使用层次锁:给锁分配层级,只能按层级顺序获取。
- 尽量少用锁:能用原子操作就别用锁,能不用锁就别用。
我曾经踩过的坑: 写一个数据库连接池,用了两个锁——一个保护连接列表,一个保护统计信息。结果一个线程先锁列表再锁统计,另一个线程反过来。线上跑了三天,连接池卡死,所有请求超时。从那以后,我强制团队所有锁必须按字母顺序加锁。
// 避免死锁:固定加锁顺序
void thread1() {
std::scoped_lock lock(m1, m2); // C++17,自动避免死锁
// 操作共享数据
}
void thread2() {
std::scoped_lock lock(m1, m2); // 顺序一致
// 操作共享数据
}
20.3 优先级反转——高优先级线程被低优先级线程拖死
优先级反转是个很有意思的问题。高优先级线程等着一个资源,但这个资源被低优先级线程占着。低优先级线程又被中优先级线程抢占了CPU,结果高优先级线程反而被中优先级线程「间接」阻塞了。
说白了:高优先级线程等低优先级线程,低优先级线程又等不到CPU,高优先级线程就卡死了。
20.3.1 真实案例
我记得在嵌入式项目中遇到过:一个实时控制线程(高优先级)需要访问共享缓冲区,但缓冲区被一个日志线程(低优先级)锁着。日志线程写到一半,被一个网络处理线程(中优先级)抢占了CPU。结果控制线程等日志线程,日志线程等网络线程,网络线程跑得欢,控制线程干着急。最后控制超时,设备报警。
20.3.2 解决方案
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 优先级继承 | 低优先级线程暂时继承高优先级线程的优先级 | 实时系统,POSIX线程支持 |
| 优先级天花板 | 锁的优先级设为所有可能请求线程中的最高值 | 嵌入式系统,资源有限 |
| 禁用中断/抢占 | 访问共享资源时禁止调度 | 内核代码,短临界区 |
pthread_mutexattr_setprotocol 可以设置。
20.4 本章知识体系图
下面这张图总结了三种并发陷阱的核心关系:
20.5 避坑指南总结
聊了这么多,我总结几条实战经验:
- 数据竞争:所有共享变量,要么加锁,要么用原子操作。别信「这个变量只读」——初始化阶段也是写。
- 死锁:团队定好加锁顺序,写在编码规范里。用
std::scoped_lock代替手动加锁。 - 优先级反转:实时系统中,用优先级继承协议。非实时系统,保证临界区足够短。
公众号:蓝海资料掘金营,微信deep3321