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特别难查,因为不是每次都复现。
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 死锁的四种避免策略
死锁是并发编程里最头疼的问题。我当年在一个通信协议栈项目里,就因为死锁问题排查了整整三天。最后发现是两个线程加锁顺序不一致导致的。
死锁产生的四个必要条件:
- 互斥:资源一次只能被一个线程使用
- 持有并等待:线程持有资源,同时等待其他资源
- 不可剥夺:资源不能被强制拿走
- 循环等待:线程之间形成循环等待链
避免死锁,说白了就是打破上面任意一个条件。常用的策略有:
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 固定加锁顺序 | 所有线程按相同顺序加锁 | 锁数量少且固定 |
| 加锁超时 | 使用 trylock,超时后释放已持有的锁 | 不确定的等待场景 |
| 锁分级 | 给锁分配等级,只能从低到高申请 | 复杂系统 |
| 避免嵌套锁 | 尽量减少一个线程持有多个锁 | 所有场景 |
我个人最推荐“固定加锁顺序”。简单粗暴,不容易出错。比如你有锁A和锁B,所有线程都先锁A再锁B,死锁就不可能发生。
38.5 知识体系总览
下面这张图把三种锁的关系和适用场景梳理清楚了。你可以把它当作一个快速参考。
38.6 实战建议
说了这么多,最后给几条实在的建议:
- 能用互斥锁就别用读写锁。读写锁的逻辑更复杂,出错的概率也更高。只有当你明确测量出互斥锁是性能瓶颈时,才考虑升级。
- 条件变量的等待条件一定要用 while 循环检查。这是C语言多线程编程的黄金法则,没有例外。
- 加锁和解锁放在同一个函数里。跨函数加解锁,很容易忘记解锁或者搞错顺序。我见过最离谱的bug,是在一个函数里加锁,在另一个完全不相关的函数里解锁——这种代码基本没法维护。
- 用工具检测死锁。比如 Valgrind 的 helgrind 工具,或者 ThreadSanitizer。别全靠肉眼排查,人眼在复杂并发逻辑面前很不靠谱。
好了,关于互斥锁、读写锁和条件变量,就聊到这里。这三种机制是并发编程的基石,理解透了,大部分多线程问题都能迎刃而解。