11、读写锁:读写锁的原理、pthread_rwlock_t的使用、读写锁的性能分析
读写锁,说白了就是一把「看情况上锁」的锁。
我刚开始学并发编程那会儿,总觉得互斥锁一把锁走天下就够了。后来在项目里做缓存系统,发现读操作占了90%以上,写操作少得可怜。用互斥锁?所有读线程排队等,白白浪费CPU。嗯,这时候读写锁就派上用场了。
读写锁的原理
读写锁的核心思想很简单:读读不互斥,读写互斥,写写互斥。
你想想看,多个线程同时读一个变量,谁也不会改它,那干嘛要互相等?只有写的时候,才需要独占资源。
- 读模式:任意多个线程可以同时持有读锁
- 写模式:只有一个线程能持有写锁,且此时不能有任何读锁
- 锁降级:写锁可以降级为读锁(但读锁不能升级为写锁,容易死锁)
关键点:读写锁适合「读多写少」的场景。如果你的写操作也很频繁,那读写锁反而可能比互斥锁更慢——因为维护读写状态本身有开销。
我在项目中遇到过一个问题:一个配置中心服务,每秒几千次读请求,写请求几分钟才一次。用互斥锁时,读线程经常被写线程阻塞,延迟飙升。换成读写锁后,读请求几乎零等待,性能提升了5倍多。
pthread_rwlock_t 的使用
POSIX 线程库提供了 pthread_rwlock_t 类型,用起来其实跟互斥锁差不多,就是多了个「读/写」的选择。
基本操作
#include <pthread.h>
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
// 初始化
pthread_rwlock_init(&rwlock, NULL);
// 加读锁
pthread_rwlock_rdlock(&rwlock);
// 加写锁
pthread_rwlock_wrlock(&rwlock);
// 尝试加锁(非阻塞)
pthread_rwlock_tryrdlock(&rwlock);
pthread_rwlock_trywrlock(&rwlock);
// 解锁
pthread_rwlock_unlock(&rwlock);
// 销毁
pthread_rwlock_destroy(&rwlock);
完整示例:缓存读写
#include <stdio.h>
#include <pthread.h>
#include <string.h>
#include <stdlib.h>
pthread_rwlock_t cache_lock = PTHREAD_RWLOCK_INITIALIZER;
char cache_data[256] = "default";
void* reader(void* arg) {
int id = *(int*)arg;
pthread_rwlock_rdlock(&cache_lock);
printf("[读线程 %d] 读取缓存: %s\n", id, cache_data);
pthread_rwlock_unlock(&cache_lock);
return NULL;
}
void* writer(void* arg) {
int id = *(int*)arg;
pthread_rwlock_wrlock(&cache_lock);
snprintf(cache_data, sizeof(cache_data), "data_from_writer_%d", id);
printf("[写线程 %d] 写入缓存: %s\n", id, cache_data);
pthread_rwlock_unlock(&cache_lock);
return NULL;
}
int main() {
pthread_t readers[5], writers[2];
int ids[7];
for (int i = 0; i < 5; i++) {
ids[i] = i;
pthread_create(&readers[i], NULL, reader, &ids[i]);
}
for (int i = 0; i < 2; i++) {
ids[5 + i] = i;
pthread_create(&writers[i], NULL, writer, &ids[5 + i]);
}
for (int i = 0; i < 5; i++) pthread_join(readers[i], NULL);
for (int i = 0; i < 2; i++) pthread_join(writers[i], NULL);
pthread_rwlock_destroy(&cache_lock);
return 0;
}
个人习惯:我一般用 PTHREAD_RWLOCK_INITIALIZER 静态初始化,省得写 init/destroy。但如果锁是动态分配的,记得用 init 和 destroy 配对。
读写锁的性能分析
读写锁到底快不快?这得看场景。
性能对比表
| 场景 | 互斥锁 | 读写锁 | 说明 |
|---|---|---|---|
| 读多写少(读占95%以上) | 差 | 优 | 读写锁优势明显,读线程几乎不竞争 |
| 读写均衡(读占50%) | 中 | 中 | 读写锁的维护开销开始显现 |
| 写多读少(写占50%以上) | 优 | 差 | 写锁频繁,读锁几乎没机会,不如互斥锁 |
| 临界区极短(几行代码) | 优 | 差 | 读写锁的上下文切换开销占比大 |
为什么会这样?我解释一下。
读写锁内部维护了「当前读者数量」和「写者等待队列」。每次加锁解锁,都要做原子操作和条件判断。这个开销比互斥锁大。所以如果你的临界区只有两三行代码,用互斥锁反而更快。
我曾经踩过的坑:在一个高并发服务里,我无脑用了读写锁,结果性能反而下降了。后来用 perf 分析才发现,临界区只有一条赋值语句,读写锁的开销占了总时间的60%。换成互斥锁后,性能直接翻倍。
读写锁的内部结构
下面这张图展示了读写锁的核心逻辑。我画了个简图,方便你理解。
性能调优建议
- 优先用互斥锁:除非你确认读操作远多于写操作,否则别轻易上读写锁。
- 临界区别太小:如果临界区只有几条指令,读写锁的开销可能比互斥锁还大。
- 注意写锁饥饿:有些实现中,如果读锁一直被持有,写锁可能永远拿不到。Linux 的 pthread_rwlock 默认是「写优先」的,但不同平台行为不同。
- 考虑用 RCU:如果你的场景是「读极多、写极少」,而且数据结构适合,RCU(Read-Copy-Update)比读写锁更高效。不过 RCU 实现复杂,我一般只在内核模块里用。
我建议:在项目初期先用互斥锁,等性能瓶颈出现后,用 perf 或 valgrind 分析锁竞争情况。如果确实读多写少,再换成读写锁。别一开始就搞复杂了。
避坑指南
最后分享几个我踩过的坑:
- 读锁升级写锁会死锁:一个线程持有读锁后,再去申请写锁,会把自己卡死。因为写锁需要等待所有读锁释放,包括它自己。
- 忘记解锁:跟互斥锁一样,读写锁也必须配对 unlock。我曾经在异常分支里忘了 unlock,导致所有读线程全部阻塞。
- 递归加锁:pthread_rwlock 默认不支持递归。如果你在持有读锁的代码里又调用了 rdlock,会死锁。需要递归的话,自己维护一个计数器。
好了,读写锁就聊到这儿。记住一句话:工具没有好坏,只有合不合适。读写锁是好东西,但别滥用。