20、常见并发陷阱:数据竞争、死锁与优先级反转

并发编程,说白了就是让多个线程一起干活。听起来很美好,但实际写起来,坑一个接一个。我做了十几年C++,踩过的坑能绕公司三圈。今天咱们就聊聊最常见的三个陷阱:数据竞争、死锁,还有优先级反转。

20.1 数据竞争(Data Race)—— 最隐蔽的杀手

数据竞争是什么?简单说:两个或多个线程同时访问同一块内存,至少有一个是写操作,而且没有任何同步机制。结果就是——程序行为完全不可预测。

⚠️ 注意: C++标准说,数据竞争是未定义行为。这意味着编译器可以生成任何代码,你的程序可能崩溃、输出错误结果,甚至看起来正常运行但偶尔抽风。

我遇到过最头疼的一个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 避免数据竞争的方法

  1. 用互斥锁保护共享数据:最直接的方式,但要注意锁的粒度。
  2. 用原子操作:对于简单的计数器、标志位,用 std::atomic<> 更高效。
  3. 用无锁数据结构:高手用的,但实现难度大,容易引入新bug。
  4. 尽量不共享数据:每个线程处理自己的数据,最后再合并。这是最干净的方案。
// 正确示例:用原子操作
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线程支持
优先级天花板 锁的优先级设为所有可能请求线程中的最高值 嵌入式系统,资源有限
禁用中断/抢占 访问共享资源时禁止调度 内核代码,短临界区
💡 我的建议: 在实时系统中,尽量让临界区短到可以忽略不计。如果做不到,就用优先级继承。C++标准库不直接支持,但POSIX线程(pthread)有 pthread_mutexattr_setprotocol 可以设置。

20.4 本章知识体系图

下面这张图总结了三种并发陷阱的核心关系:

并发编程三大陷阱 数据竞争 死锁 优先级反转 未定义行为 程序崩溃 / 结果错误 线程永久阻塞 系统卡死 实时任务超时 系统响应延迟 解决方案 互斥锁 / 原子操作 固定加锁顺序 优先级继承 少共享 核心原则:能不用锁就不用,必须用就按规矩用

20.5 避坑指南总结

聊了这么多,我总结几条实战经验:

  • 数据竞争:所有共享变量,要么加锁,要么用原子操作。别信「这个变量只读」——初始化阶段也是写。
  • 死锁:团队定好加锁顺序,写在编码规范里。用 std::scoped_lock 代替手动加锁。
  • 优先级反转:实时系统中,用优先级继承协议。非实时系统,保证临界区足够短。
⚠️ 最后提醒: 并发bug最难复现,也最难调试。我见过太多「重启就好了」的案例——那不是好了,是bug藏起来了。写代码时多花十分钟考虑并发安全,能省下后面十天的排查时间。

公众号:蓝海资料掘金营,微信deep3321