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 知识体系总览

下面这张图是我梳理的多线程崩溃根源知识体系,方便你整体把握:

多线程崩溃根源知识体系 多线程崩溃根源 数据竞争 多个线程并发访问 共享内存 至少一个写操作 无同步机制 死锁 互斥 + 持有并等待 不可剥夺 + 循环等待 线程互相等待 程序完全卡死 活锁 线程都在运行 CPU跑满 但毫无进展 不断重试/退让 饥饿 线程一直拿不到资源 虚线表示饥饿可能演变为死锁或活锁

3.6 总结对比

问题 表现 线程状态 CPU使用率 排查难度
数据竞争 结果错误、偶发崩溃 运行中 正常 ★★★★★
死锁 程序完全卡住 阻塞等待 极低 ★★★
活锁 程序在跑但没进展 运行中 极高 ★★★★
饥饿 部分线程无响应 可运行但未调度 正常或偏高 ★★★★

说实话,这四个问题里,数据竞争是最难排查的。因为它不一定会稳定复现,而且表现千奇百怪。死锁反而是最好定位的——线程堆栈一看就知道。活锁和饥饿介于两者之间。

我个人习惯在项目初期就引入 ThreadSanitizer 和 AddressSanitizer,在测试阶段就把数据竞争和死锁扼杀在摇篮里。别等到线上出问题了再查,那代价就大了。

可可精品资料库