4. 线程安全与锁机制
说到并发编程,锁是绕不开的话题。很多人觉得锁就是加个mutex完事,但其实选错锁、用错锁,性能会差一个数量级。我这些年排查过的线上事故,至少有一半跟锁有关。不是死锁就是锁竞争太激烈,把CPU拖成养老模式。
这一节,咱们就掰开揉碎讲清楚。从临界区怎么找,到互斥锁、读写锁怎么选,再到死锁怎么防。你把这些搞明白了,起码能避开80%的并发坑。
4.1 临界区的识别 —— 先找对地方,再谈优化
很多人一上来就加锁,觉得“锁住就安全了”。其实这是错的。你先把临界区找对了,锁才有意义。否则锁得越多,出Bug的概率越大。
什么是临界区?说白了就是多个线程同时访问同一份共享数据,并且至少有一个线程在写的那段代码。我见过最典型的问题,是把一个不相关的变量也锁了,结果引起“锁的连锁反应”,性能直接腰斩。
识别临界区的三步法:
- 找共享变量 —— 全局变量、静态变量、堆上的对象,这些是所有线程都能碰到的。
- 找读写冲突 —— 只要有一个线程写,其他线程读,就算是冲突。别等到读写出错才回头。
- 界定保护范围 —— 只锁必要的代码行,别把整个函数锁了。
我个人习惯,在代码里给每个共享变量都加个注释,写清楚“哪些线程会写它、哪些线程会读它”。这招看着笨,其实特别管用。有一次一个新同事接手代码,就是靠这些注释迅速定位了Bug。
4.2 互斥锁(Mutex)的实现 —— 别把简单的事做复杂
Mutex 是最基础的锁。它的核心就两件事:lock 和 unlock。但这两件事做不对,就会出大问题。
咱们看看 POSIX 线程库的 Mutex 长什么样:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void safe_increment(int *counter) {
pthread_mutex_lock(&lock);
(*counter)++; // 临界区
pthread_mutex_unlock(&lock);
}
看起来简单吧?但你想想看,如果加锁和解锁不在同一个代码块,或者忘记解锁,那怎么办?我曾经在项目中遇到过这样的Bug:一个异常分支直接 return 了,没调 unlock,结果整个进程就“冻住”了。
避坑指南:
- 解锁一定要保证执行到,用 RAII 思想(C++ 的 lock_guard,Java 的 synchronized)是最保险的。
- 不要在锁内调用可能阻塞的外部函数(比如网络IO、文件读写),否则其他线程都得等着。
- 锁的粒度要小,持有锁的时间要短——能1微秒就别拖到1秒。
4.2.1 自旋锁 vs. 睡眠锁
你可能会问,Mutex 底层是怎么实现的?其实有两种主流策略:
| 特性 | 自旋锁 | 睡眠锁(标准 Mutex) |
|---|---|---|
| 等待方式 | CPU 空转轮询 | 线程挂起,让出CPU |
| 适用场景 | 锁持有时间极短(纳秒级) | 锁持有时间较长(微秒以上) |
| 上下文切换 | 无 | 有,开销大 |
| 缺点 | 占用CPU | 切换延迟 |
我在HSM SDK的底层接口优化里,就把一些关键的短临界区从标准 Mutex 换成了自旋锁。效果很明显,响应时间从微秒级降到了纳秒级。但记住,自旋锁不是万能药——要是临界区太长,它反而会浪费CPU,比睡眠锁还慢。
4.3 读写锁(RWLock)的适用场景 —— 别让读等读
很多场景是读多写少,比如配置表、路由表、缓存。这时候用普通 Mutex 就亏了——读操作之间根本不冲突,你锁它干嘛?
读写锁就是为解决这个问题来的:
- 读锁:可以同时有多个线程持有。谁都不等谁。
- 写锁:只能有一个线程持有。写的时候,读和写都得等着。
你看下面这段代码,是不是就比 Mutex 优雅多了?
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
// 读操作
pthread_rwlock_rdlock(&rwlock);
read_config(); // 多个线程同时读,没问题
pthread_rwlock_unlock(&rwlock);
// 写操作
pthread_rwlock_wrlock(&rwlock);
update_config(); // 只能一个线程写
pthread_rwlock_unlock(&rwlock);
读写锁的适用条件:
- 读操作远多于写操作(比例至少 10:1 以上)
- 读操作耗时短(否则写线程会被饿死)
- 写操作不频繁(秒级甚至分钟级一次)
我记得有一次做密钥分发服务,全局密钥表有几千条记录,每秒要读几万次,但写(更新密钥)只有一次。用普通 Mutex 的话,读线程之间互相阻塞,吞吐量直接少了一个0。换成 RWLock 后,读线程几乎完全没有竞争,性能翻了三倍。
但是,这里有个坑要留意:如果写操作特别频繁,读写锁反而比 Mutex 还差。为什么?因为写锁会阻塞所有读操作,如果写的频率高,读线程就一直在等待,CPU大部分时间都在切换上下文。所以具体用哪种锁,一定要结合业务场景来评估。
4.4 死锁的预防与排查 —— 最难查的Bug之一
死锁这东西,不是“遇到了才紧张”,而是不遇到永远觉得不会出事。但只要你一个不注意,它就能让你的程序“假死”——CPU正常跑,业务就是不响应。
经典的死锁场景,就像两个人过独木桥:你等我让开,我等你让开,结果谁都不动。在多线程里,就是A线程持有锁1等锁2,B线程持有锁2等锁1。谁都不放手,谁也别想往前走。
4.4.1 死锁的四个必要条件
这四个条件必须同时满足才会死锁,缺一不可:
- 互斥:资源只能一个线程用(锁的特性)
- 持有并等待:线程拿着锁A,又去申请锁B
- 不可剥夺:锁只能自己释放,别人抢不走
- 循环等待:形成闭环 A等B,B等C,C等A
排查工具推荐:
- Linux上用
gdb或pstack查看线程堆栈,看哪个线程在等哪把锁。 - 或者用
valgrind --tool=helgrind静态分析锁依赖,它能直接告诉你潜在的死锁风险。 - Java环境用
jstack导出线程dump,然后人工分析。
4.4.2 预防死锁的策略
与其等死锁出现了再排查,不如直接从源头堵死。我总结了三条铁律:
- 统一加锁顺序——比如所有线程都先锁 A 再锁 B,就不会出现 A等B、B等A的循环。
- 使用“锁超时”机制——尝试加锁时设置一个超时,拿不到就释放自己手里的锁,再重试。
- 尽量少用嵌套锁——能用一个锁解决的,就别用两个。
我曾经在一套密钥生成服务里,遇到过这样的死锁:线程1持有了密钥存储锁,然后去获取状态锁;线程2持有了状态锁,然后去获取密钥存储锁。两个线程互相等,整个密钥生成流程就卡死了。排查了两个小时,最后还是靠 gdb 查看线程堆栈找到的。从那以后,我就把所有需要多锁的操作,统一规定了一个加锁顺序:先锁存储,再锁状态。这个死锁再也没有出现过。
知识体系总结
这一章的知识,我画成了一张图,方便你复习时快速回忆。核心就是从“临界区”出发,根据读写比例和锁持有时间来选择合适的锁,然后用加锁顺序和超时来预防死锁。
记住,锁不是越多越好,而是越精确越好。你找对了临界区,选对了锁类型,再防住了死锁,你的并发程序就已经成功了一大半。