38、互斥锁与条件变量:互斥锁、读写锁、条件变量的使用与死锁避免

多线程编程,说白了就是让多个工人同时干活。但问题来了——如果两个工人同时抢一把锤子,那肯定要打架。互斥锁、读写锁、条件变量,就是用来管好这些“锤子”的机制。我做了十几年嵌入式,见过太多因为锁没用好导致的诡异bug。今天咱们就把这几个家伙彻底聊透。

38.1 互斥锁:最基础的“门禁系统”

互斥锁(Mutex)是最简单的同步工具。它的作用就一个:同一时间,只有一个线程能访问共享资源

举个实际例子。我在做车载导航项目时,多个线程都要写日志文件。如果不加锁,日志内容会混在一起,根本没法看。

#include <pthread.h>

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void* write_log(void* arg) {
    pthread_mutex_lock(&lock);
    // 临界区:写日志文件
    fprintf(log_file, "线程 %ld 写入日志\n", (long)arg);
    pthread_mutex_unlock(&lock);
    return NULL;
}

嗯,这里要注意:加锁和解锁必须成对出现。我见过有人写代码,加锁后忘了解锁,结果其他线程全部卡死。这种bug特别难查,因为不是每次都复现。

⚠️ 死锁风险:如果线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。两个线程就互相“死等”了。这就是经典的死锁。

38.2 读写锁:读多写少的优化方案

你想想看,如果只是读数据,多个线程同时读其实没问题。但互斥锁不管这些,它一刀切:只要有人用,别人就别想碰。这太浪费了。

读写锁(RWLock)就聪明多了:

  • 读模式:多个线程可以同时加读锁
  • 写模式:只有一个线程能加写锁,且不能有读锁存在
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;

// 读操作
void read_data() {
    pthread_rwlock_rdlock(&rwlock);
    // 读取共享数据
    pthread_rwlock_unlock(&rwlock);
}

// 写操作
void write_data() {
    pthread_rwlock_wrlock(&rwlock);
    // 修改共享数据
    pthread_rwlock_unlock(&rwlock);
}

我在一个配置管理模块里用过读写锁。配置信息几乎不会被修改,但几十个线程会频繁读取。用互斥锁的话,读线程之间也要互相等待,性能差得离谱。换成读写锁后,读操作完全并行,效率提升了好几倍。

💡 使用建议:如果你的场景是“读多写少”,读写锁是首选。但如果是“写多读少”,读写锁反而可能比互斥锁更慢,因为写锁的优先级处理有额外开销。

38.3 条件变量:让线程学会“等待通知”

互斥锁和读写锁解决的是“互斥访问”问题。但有时候,线程需要等待某个条件成立才能继续执行。比如生产者-消费者模型:消费者要等生产者生产出数据才能消费。

条件变量就是干这个的。它让线程可以阻塞等待某个条件,而不是忙等待(空转CPU)。

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;

// 生产者
void* producer(void* arg) {
    pthread_mutex_lock(&mutex);
    // 生产数据
    data_ready = 1;
    pthread_cond_signal(&cond);  // 通知消费者
    pthread_mutex_unlock(&mutex);
    return NULL;
}

// 消费者
void* consumer(void* arg) {
    pthread_mutex_lock(&mutex);
    while (!data_ready) {
        pthread_cond_wait(&cond, &mutex);  // 等待条件
    }
    // 消费数据
    pthread_mutex_unlock(&mutex);
    return NULL;
}

这里有个关键点:pthread_cond_wait 会原子性地释放互斥锁并进入等待。被唤醒后,它会重新获取锁。我刚开始用的时候,总搞不清锁的状态,结果写出了死锁。后来养成一个习惯:条件变量一定要配合 while 循环使用,而不是 if。因为可能有“虚假唤醒”的情况。

🔑 核心原则:条件变量永远和互斥锁配合使用。检查条件、等待条件、修改条件,这三个操作必须在同一个互斥锁的保护下完成。

38.4 死锁的四种避免策略

死锁是并发编程里最头疼的问题。我当年在一个通信协议栈项目里,就因为死锁问题排查了整整三天。最后发现是两个线程加锁顺序不一致导致的。

死锁产生的四个必要条件:

  1. 互斥:资源一次只能被一个线程使用
  2. 持有并等待:线程持有资源,同时等待其他资源
  3. 不可剥夺:资源不能被强制拿走
  4. 循环等待:线程之间形成循环等待链

避免死锁,说白了就是打破上面任意一个条件。常用的策略有:

策略 做法 适用场景
固定加锁顺序 所有线程按相同顺序加锁 锁数量少且固定
加锁超时 使用 trylock,超时后释放已持有的锁 不确定的等待场景
锁分级 给锁分配等级,只能从低到高申请 复杂系统
避免嵌套锁 尽量减少一个线程持有多个锁 所有场景

我个人最推荐“固定加锁顺序”。简单粗暴,不容易出错。比如你有锁A和锁B,所有线程都先锁A再锁B,死锁就不可能发生。

⚠️ 我曾经踩过的坑:在回调函数里加锁。回调函数可能被不同的线程调用,加锁顺序完全不可控。后来我改成在回调函数里用消息队列,把操作异步化,彻底避免了死锁问题。

38.5 知识体系总览

下面这张图把三种锁的关系和适用场景梳理清楚了。你可以把它当作一个快速参考。

并发同步机制知识体系 互斥锁 (Mutex) 读写锁 (RWLock) 条件变量 (Cond) 特性 • 独占访问 • 简单直接 • 性能开销最小 特性 • 读读并行 • 读写互斥 • 适合读多写少 特性 • 等待通知 • 避免忙等待 • 必须配合互斥锁 死锁避免:固定加锁顺序 + 超时机制 核心原则:锁的粒度要小,持有时间要短,加锁顺序要统一

38.6 实战建议

说了这么多,最后给几条实在的建议:

  • 能用互斥锁就别用读写锁。读写锁的逻辑更复杂,出错的概率也更高。只有当你明确测量出互斥锁是性能瓶颈时,才考虑升级。
  • 条件变量的等待条件一定要用 while 循环检查。这是C语言多线程编程的黄金法则,没有例外。
  • 加锁和解锁放在同一个函数里。跨函数加解锁,很容易忘记解锁或者搞错顺序。我见过最离谱的bug,是在一个函数里加锁,在另一个完全不相关的函数里解锁——这种代码基本没法维护。
  • 用工具检测死锁。比如 Valgrind 的 helgrind 工具,或者 ThreadSanitizer。别全靠肉眼排查,人眼在复杂并发逻辑面前很不靠谱。
💡 我的小习惯:每次写完多线程代码,我都会在脑子里模拟一遍执行流程。特别是加锁解锁的路径,确保每个分支都能正确释放锁。这个习惯帮我避免了好几次线上事故。

好了,关于互斥锁、读写锁和条件变量,就聊到这里。这三种机制是并发编程的基石,理解透了,大部分多线程问题都能迎刃而解。

专注资料整理