11、锁优化策略:读写锁、自旋锁、无锁编程(CAS原子操作)、减少锁粒度
说到多线程编程,锁是个绕不开的话题。很多新手一上来就怼个大锁,把所有操作都包进去——嗯,这样确实简单,但性能嘛……惨不忍睹。我这些年调过的性能问题,十有八九都跟锁有关。今天咱们就聊聊锁优化的那些事儿。
11.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);
}
我的经验:读写锁在配置管理、路由表查找这类场景下效果特别好。我曾经在一个网关项目里,把普通互斥锁换成读写锁,吞吐量直接翻了一倍。但要注意——如果写操作很频繁,读写锁反而可能比互斥锁更慢,因为写者会被读者饿死。
11.2 自旋锁:短小精悍的等待
自旋锁和普通锁最大的区别是:它不睡眠,而是原地打转。
为什么会有人用这种看起来浪费CPU的方式?因为线程睡眠再唤醒的开销很大,上下文切换可能要几微秒。如果你锁的持有时间只有几十纳秒,那自旋反而更划算。
#include <linux/spinlock.h>
spinlock_t my_lock = SPIN_LOCK_UNLOCKED;
void critical_section() {
spin_lock(&my_lock);
// 极短的操作,比如修改一个计数器
spin_unlock(&my_lock);
}
避坑指南:我曾经在一个项目中,有人在自旋锁保护的临界区里调用了sleep()——结果整个系统卡死了。自旋锁的临界区里绝对不能睡眠、不能调用可能阻塞的函数。记住:自旋锁是给微秒级操作用的,不是给你做复杂计算的。
11.3 无锁编程:CAS原子操作
说到无锁编程,很多人觉得高深莫测。其实说白了,就是用CPU提供的原子指令来替代锁。
最常用的就是CAS(Compare And Swap)。它做一件事:比较内存值和期望值,如果相等就更新为新值,整个过程不可中断。
#include <stdatomic.h>
atomic_int counter = 0;
void safe_increment() {
int expected;
int desired;
do {
expected = atomic_load(&counter);
desired = expected + 1;
} while (!atomic_compare_exchange_weak(&counter, &expected, desired));
}
这段代码看起来有点绕,但核心逻辑很简单:先读当前值,计算新值,然后尝试用CAS更新。如果别人改了,就重试。
关键点:无锁编程不是真的没有锁,而是把锁的粒度降到了CPU指令级别。它的优势在于:不会引起线程调度、不会死锁、在高并发下性能更好。
但无锁编程也有坑。我记得有一次调试一个无锁队列,ABA问题折腾了我整整两天。简单说就是:线程A读到值是X,然后被挂起;线程B把X改成Y又改回X;线程A醒来后CAS成功,但数据已经变了。解决方案是加版本号,或者用双字CAS。
11.4 减少锁粒度:把大锁拆小
这个思路最直接:锁的范围越小,竞争就越少。
具体做法有几种:
- 锁分解:把一个大锁拆成多个小锁,每个保护不同的数据
- 锁分段:比如哈希表,每个桶一把锁
- 读写分离:读操作不加锁,写操作加锁
// 粗粒度锁
pthread_mutex_t big_lock;
void process_all() {
pthread_mutex_lock(&big_lock);
// 处理1000个元素
for (int i = 0; i < 1000; i++) {
process_item(i);
}
pthread_mutex_unlock(&big_lock);
}
// 细粒度锁
pthread_mutex_t locks[10];
void process_all_fine() {
for (int i = 0; i < 1000; i++) {
int bucket = i % 10;
pthread_mutex_lock(&locks[bucket]);
process_item(i);
pthread_mutex_unlock(&locks[bucket]);
}
}
我的建议:别一上来就搞细粒度锁。先分析热点在哪里,然后只优化瓶颈。我见过有人把锁拆得太细,结果锁本身的管理开销比竞争开销还大——得不偿失。
11.5 四种策略对比
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 读写锁 | 读多写少 | 读并发高 | 写者可能饿死 |
| 自旋锁 | 临界区极短 | 无上下文切换 | 浪费CPU |
| CAS无锁 | 简单操作 | 无阻塞 | 实现复杂 |
| 减少粒度 | 大数据结构 | 降低竞争 | 管理开销增加 |
11.6 知识体系图
11.7 实战建议
说了这么多,到底该怎么选?我个人的经验是:
- 先测量,再优化。别猜,用perf或者gprof看看锁竞争到底有多严重
- 从最简单的方案开始。普通互斥锁不行了,再考虑读写锁或自旋锁
- 无锁编程是最后的手段。除非你真的很熟悉内存模型和CPU架构,否则别轻易碰
- 测试要覆盖高并发场景。有些bug只在压力下才会出现
血的教训:我曾经在一个金融交易系统里,为了追求极致性能,用了无锁队列。结果上线后偶尔出现数据不一致,查了整整一周才发现是内存重排序导致的。从那以后,我学乖了——能用锁解决的问题,别搞花活。
锁优化没有银弹。读写锁、自旋锁、CAS、减少粒度——每种方案都有自己的适用场景。关键是要理解你的业务特点,然后对症下药。嗯,今天就聊到这儿,希望这些经验对你有帮助。