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,普通互斥锁可能更合适。我见过有人为了炫技用读写锁,结果性能反而更差。

好了,关于互斥锁的进阶内容就聊到这里。死锁的预防、递归锁的使用、读写锁的选型,这些都是实战中绕不开的坎。记住一句话:锁是工具,不是目的。能用简单锁解决的问题,就别搞复杂了。

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