3、多线程崩溃根源:数据竞争、死锁、活锁、饥饿
多线程编程,说白了就是让多个工人同时干活。但工人多了,矛盾就来了。我见过太多线上事故,最后定位到根源就是这四个家伙:数据竞争、死锁、活锁、饥饿。今天咱们一个一个掰扯清楚。
3.1 数据竞争(Data Race)—— 最隐蔽的杀手
数据竞争是什么?两个或多个线程同时访问同一块内存,至少有一个是写操作,而且没有任何同步机制保护。结果就是——你永远不知道读到的是什么值。
我个人习惯把数据竞争比作「两个人同时改一份合同」。你改你的,我改我的,最后谁都不知道最终版本长什么样。
核心定义:多个线程并发访问共享数据,至少一个线程执行写操作,且没有使用任何同步原语(如互斥锁、原子操作)来协调访问。
来看一个典型的例子:
// 典型的数据竞争示例
int counter = 0;
void worker() {
for (int i = 0; i < 100000; ++i) {
++counter; // 这里不是原子操作!
}
}
int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
std::cout << counter << std::endl; // 结果大概率不是200000
return 0;
}
为什么会这样?++counter 在底层其实是三步:读、改、写。两个线程可能同时读到同一个值,各自加1,再写回去。结果只加了1次,而不是2次。
避坑指南:我曾经在一个高并发服务里遇到过类似问题。计数器统计请求量,上线后发现数据对不上。查了两天才发现是数据竞争。解决方案很简单:用 std::atomic<int> 或者加锁。
数据竞争在C++里属于「未定义行为」。这意味着编译器可以优化出任何结果,甚至让你的程序直接崩溃。嗯,这里要注意,不是「可能出错」,而是「一定出错」,只是你不知道什么时候爆。
3.2 死锁(Deadlock)—— 互相等待的困局
死锁,你想想看,就是两个线程互相等对方释放资源。A拿着锁1等锁2,B拿着锁2等锁1。结果谁也别想往前走。
我记得有一次线上服务突然全部卡死,所有请求都超时。上去一看,线程堆栈全停在锁等待上。典型的死锁场景。
死锁四条件(缺一不可):
- 互斥:资源一次只能被一个线程占用
- 持有并等待:线程拿着一个资源,同时等待另一个资源
- 不可剥夺:资源不能被强制拿走,只能主动释放
- 循环等待:存在一个线程-资源的环形链
代码示例:
std::mutex mtx1, mtx2;
void threadA() {
std::lock_guard<std::mutex> lock1(mtx1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock2(mtx2); // 等mtx2
// 干活...
}
void threadB() {
std::lock_guard<std::mutex> lock2(mtx2);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock1(mtx1); // 等mtx1
// 干活...
}
两个线程同时启动,A锁了mtx1等mtx2,B锁了mtx2等mtx1。完美死锁。
避坑指南:我曾经在一个支付系统里遇到过死锁。两个线程分别持有账户A和账户B的锁,然后互相转账。解决方案是「固定锁顺序」——所有线程都按账户ID从小到大加锁。这样就不会有循环等待了。
死锁的排查工具:gdb 看线程堆栈,或者用 ThreadSanitizer 在测试阶段就能检测到。
3.3 活锁(Livelock)—— 忙忙碌碌却毫无进展
活锁比死锁更气人。线程们都在运行,CPU跑满,但就是干不了正事。就像两个人面对面让路,你往左我也往左,你往右我也往右,永远错不开。
活锁的典型场景:两个线程检测到冲突后,各自释放资源,然后重试。但重试的时机总是撞在一起,导致无限循环。
// 活锁的简化示例
std::atomic<bool> flag1{false}, flag2{false};
void threadA() {
while (true) {
flag1.store(true);
if (flag2.load() == false) {
// 干活...
break;
}
flag1.store(false);
std::this_thread::yield(); // 让出CPU,但可能又同时回来
}
}
void threadB() {
while (true) {
flag2.store(true);
if (flag1.load() == false) {
// 干活...
break;
}
flag2.store(false);
std::this_thread::yield();
}
}
两个线程同时设置标志,同时检测到对方,同时释放,同时yield,然后又同时开始...循环往复。
避坑指南:我建议在重试逻辑中加入随机退避。比如每次重试前等待一个随机毫秒数。这样两个线程撞车的概率就大大降低了。说白了,就是给它们「错峰出行」的机会。
3.4 饥饿(Starvation)—— 被遗忘的线程
饥饿,就是某个线程一直拿不到它需要的资源。不是死锁,也不是活锁,就是单纯地「抢不过别人」。
举个例子:一个高优先级线程不断获取锁,低优先级线程永远排不上队。或者读写锁中,读线程太多,写线程一直等不到机会。
饥饿的常见原因:
- 优先级调度不当——低优先级线程被高优先级线程无限抢占
- 不公平的锁——某些锁实现偏向于最近请求的线程
- 读多写少场景——读写锁中写线程被读线程无限阻塞
我记得在一个日志系统中遇到过饥饿问题。写日志的线程优先级设得很低,结果业务线程疯狂产生日志,写线程根本来不及处理。最后日志缓冲区爆了,程序崩溃。
避坑指南:我曾经用 std::shared_mutex 时踩过坑。读线程太多,写线程一直拿不到独占锁。解决方案是改用公平锁,或者限制并发读线程数量。嗯,这里要注意,C++标准库的 std::shared_mutex 不保证公平性。
3.5 知识体系总览
下面这张图是我梳理的多线程崩溃根源知识体系,方便你整体把握:
3.6 总结对比
| 问题 | 表现 | 线程状态 | CPU使用率 | 排查难度 |
|---|---|---|---|---|
| 数据竞争 | 结果错误、偶发崩溃 | 运行中 | 正常 | ★★★★★ |
| 死锁 | 程序完全卡住 | 阻塞等待 | 极低 | ★★★ |
| 活锁 | 程序在跑但没进展 | 运行中 | 极高 | ★★★★ |
| 饥饿 | 部分线程无响应 | 可运行但未调度 | 正常或偏高 | ★★★★ |
说实话,这四个问题里,数据竞争是最难排查的。因为它不一定会稳定复现,而且表现千奇百怪。死锁反而是最好定位的——线程堆栈一看就知道。活锁和饥饿介于两者之间。
我个人习惯在项目初期就引入 ThreadSanitizer 和 AddressSanitizer,在测试阶段就把数据竞争和死锁扼杀在摇篮里。别等到线上出问题了再查,那代价就大了。
可可精品资料库