5、互斥锁进阶:死锁的产生与预防、递归锁、读写锁
各位同学,咱们今天聊点硬核的。互斥锁的基本用法,我相信你们已经玩得挺溜了。但实际项目中,锁这玩意儿用不好,那就是一把双刃剑。我见过太多线上事故,都是因为锁没用好,程序直接卡死,或者性能一落千丈。
今天这一讲,咱们就深入聊聊锁的进阶玩法。说白了,就是解决三个核心问题:怎么避免把自己锁死?怎么在同一个线程里重复加锁?怎么让读多写少的场景跑得更快?
本章核心知识点
- 死锁的四个必要条件 & 预防策略
- 递归锁(Reentrant Mutex)的使用场景与代价
- 读写锁(RWLock)的原理与实战
5.1 死锁:程序员的噩梦
先说说死锁。什么是死锁?说白了就是两个线程互相等着对方释放锁,结果谁也跑不了。程序就像被按了暂停键,永远停在那里。
我记得有一次线上排查问题,一个服务突然不响应了。我上去一看,CPU 占用率极低,但请求就是处理不完。用 GDB 挂上去一看,好家伙,两个线程各自持有一把锁,都在等对方的那把。这就是典型的死锁。
5.1.1 死锁的四个必要条件
死锁的发生,必须同时满足四个条件。你想想看,缺一个都死不了:
| 条件 | 说明 |
|---|---|
| 互斥 | 资源一次只能被一个线程占用 |
| 持有并等待 | 线程拿着一个锁,还在等另一个锁 |
| 不可剥夺 | 别人拿着的锁,你不能强行抢过来 |
| 循环等待 | 线程 A 等 B,B 等 A,形成环路 |
⚠️ 避坑指南:我曾经在一个项目中,因为两个线程加锁顺序不一致,导致死锁。排查了整整一个下午。后来我养成了一个习惯:所有线程加锁的顺序必须全局统一。比如先锁 A 再锁 B,那就所有人都这么干。
5.1.2 死锁预防实战
预防死锁,我常用的就两招:
第一招:固定加锁顺序。这是最有效的办法。所有线程都按照同一个顺序获取锁,循环等待就不可能发生。
// 错误示范:两个线程加锁顺序不一致
// 线程1: lock(A) -> lock(B)
// 线程2: lock(B) -> lock(A) // 死锁风险!
// 正确做法:全局统一顺序
// 线程1: lock(A) -> lock(B)
// 线程2: lock(A) -> lock(B) // 安全!
第二招:使用超时机制。如果等不到锁,就放弃已经持有的锁,退回去重试。我习惯用 pthread_mutex_timedlock:
#include <pthread.h>
#include <time.h>
int try_lock_with_timeout(pthread_mutex_t *mutex, int timeout_ms) {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += timeout_ms / 1000;
ts.tv_nsec += (timeout_ms % 1000) * 1000000;
int ret = pthread_mutex_timedlock(mutex, &ts);
if (ret == ETIMEDOUT) {
// 超时了,别死等
return -1;
}
return 0;
}
💡 我的习惯:在复杂项目中,我一般两种方法结合用。固定顺序保证不出大问题,超时机制作为兜底。万一有人写代码不守规矩,超时还能救你一命。
5.2 递归锁:同一个线程的自我救赎
好,接下来聊聊递归锁。这玩意儿,说白了就是允许同一个线程多次加锁。
你可能会问:同一个线程为什么要加两次锁?嗯,我举个例子。假设你有一个函数 func_a() 里面加了锁,然后它又调用了 func_b(),而 func_b() 也需要加同一把锁。如果是普通互斥锁,那 func_b() 一加锁就死锁了——因为锁已经被自己拿着了。
递归锁就是解决这个问题的。它内部维护了一个计数器,同一个线程每加一次锁,计数器加1;每解一次锁,计数器减1。只有计数器归零了,其他线程才能拿到锁。
#include <pthread.h>
pthread_mutex_t mutex;
pthread_mutexattr_t attr;
// 初始化递归锁
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init(&mutex, &attr);
void func_a() {
pthread_mutex_lock(&mutex);
// 做一些事情...
func_b(); // 内部也会加锁
pthread_mutex_unlock(&mutex);
}
void func_b() {
pthread_mutex_lock(&mutex); // 同一线程,不会死锁
// 做其他事情...
pthread_mutex_unlock(&mutex);
}
⚠️ 注意:递归锁不是免费的午餐。每次加解锁都要维护计数器,性能比普通互斥锁要差。我个人建议:能用普通互斥锁就别用递归锁。如果代码设计得好,完全可以通过重构避免递归调用。
5.3 读写锁:读多写少的利器
最后说说读写锁。这是我在项目中用得最多的锁之一。
你想想看,很多场景下,读操作远远多于写操作。比如一个配置表,99% 的时间都在读,只有 1% 的时间在更新。如果用普通互斥锁,读和读之间也要互斥,这就太浪费了。
读写锁的核心思想就是:读读不互斥,读写互斥,写写互斥。说白了,多个线程可以同时读,但只要有一个线程在写,其他人都得等着。
#include <pthread.h>
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);
}
实战经验:我在做一个缓存系统时,用了读写锁。读请求每秒几万次,写请求每秒才几十次。用普通互斥锁,CPU 直接飙到 80%。换成读写锁后,CPU 降到 20% 以下。效果立竿见影。
5.3.1 读写锁的陷阱
不过,读写锁也不是万能的。我遇到过一个问题:当读线程特别多的时候,写线程可能一直拿不到锁。因为读锁是共享的,只要有一个读线程还在读,写线程就得等着。如果读线程源源不断,写线程就永远排不上队。
这叫做「写者饥饿」问题。解决办法?嗯,有些实现支持写优先模式,比如 pthread_rwlockattr_setkind_np 可以设置写优先。我个人建议:如果写操作对延迟敏感,就别用读写锁,老老实实用互斥锁。
| 锁类型 | 适用场景 | 性能特点 |
|---|---|---|
| 普通互斥锁 | 读写比例接近,或写操作频繁 | 简单、高效、公平 |
| 递归锁 | 同一线程需要重复加锁 | 有额外开销,尽量少用 |
| 读写锁 | 读多写少,读操作远多于写操作 | 读并发高,写可能饥饿 |
💡 选型建议:别一上来就用读写锁。先问问自己:读操作真的比写操作多很多吗?如果比例不到 10:1,普通互斥锁可能更合适。我见过有人为了炫技用读写锁,结果性能反而更差。
好了,关于互斥锁的进阶内容就聊到这里。死锁的预防、递归锁的使用、读写锁的选型,这些都是实战中绕不开的坎。记住一句话:锁是工具,不是目的。能用简单锁解决的问题,就别搞复杂了。