深入理解读写锁:为什么读操作也需要加锁?
读写锁是 “读多写少” 场景下的性能最优解,它通过 “读共享、写独占” 的机制,既保证了共享资源的安全性(避免脏读、数据混乱),又最大化了读操作的并发效率,解决了互斥锁串行读的性能瓶颈。而读锁的存在,不是多余的,而是为了协调读与写的关系 —— 它保护的不是读操作本身,而是读操作的正确性,避免读线程拿到写操作过程中的 “半成品” 数据,同时防止写操作被读操作打断。结合你之前的代码,我们可以做一个简单
在多线程编程中,锁是保证共享资源安全的核心工具,但并非所有场景都适合用同一种锁。之前写过一段用互斥锁实现多线程读操作的代码,运行后发现一个尴尬的问题:明明多个读线程只是 “读取” 共享数据,没有修改,却只能串行执行,性能大打折扣。这时候,读写锁(rwlock)就该登场了。
很多刚接触多线程的开发者都会有这样的疑问:读写锁到底是什么?为什么要专门用它?读操作既然是共享的,直接读不就行了,为什么还要加读锁?今天就用通俗的语言,结合实际代码案例,把这些问题讲透。
一、先搞懂:读写锁到底是什么?
读写锁,顾名思义,就是区分 “读” 和 “写” 的锁,它本质上是一种升级后的互斥锁,核心思想是:读共享、写独占。
我们可以把共享资源想象成一个公共图书馆,线程就是来图书馆的人:
- 读线程 = 来图书馆看书的人,大家可以同时看同一本书(共享访问),互不干扰;
- 写线程 = 来图书馆修改书籍内容的人,必须等所有人都看完(所有读线程释放读锁),才能开始修改;同时,修改期间,任何人都不能看书(读线程不能加读锁,写线程不能加写锁)。
和普通互斥锁(不管读还是写,同一时刻只能有一个线程访问)相比,读写锁的优势在于:多线程并发读时,不需要互相等待,能极大提升读密集型场景的性能。
在 POSIX 标准中,读写锁的核心接口很简单(结合 C 语言举例,对应你之前的代码场景):
c<pthread.h>
// 1. 定义读写锁
pthread_rwlock_t rwlock;
// 2. 初始化读写锁(两种方式)
pthread_rwlock_init(&rwlock, NULL); // 动态初始化
// 或 静态初始化(类似互斥锁的PTHREAD_MUTEX_INITIALIZER)
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
// 3. 加读锁(读线程调用)
pthread_rwlock_rdlock(&rwlock);
// 4. 加写锁(写线程调用)
pthread_rwlock_wrlock(&rwlock);
// 5. 解锁(读锁、写锁解锁用同一个接口)
pthread_rwlock_unlock(&rwlock);
// 6. 销毁读写锁
pthread_rwlock_destroy(&rwlock);
这里补充两个关键细节,避免大家踩坑:一是读写锁的解锁接口统一,不管是读锁还是写锁,都用 pthread_rwlock_unlock 解锁,不用区分读解锁和写解锁;二是静态初始化和动态初始化二选一,动态初始化后必须销毁,静态初始化可省略销毁步骤(但建议统一销毁,养成良好习惯)。
二、为什么要使用读写锁?—— 解决互斥锁的性能瓶颈
回到你之前写的互斥锁代码:5 个读线程,每个执行 3 次 100ms 的读操作,理论上如果能并发读,总耗时应该接近 300ms(3 次操作 ×100ms),但实际运行总耗时却接近 1500ms,这就是互斥锁的致命缺点 ——不分读写,一刀切独占。
互斥锁的核心逻辑是 “同一时刻只能有一个线程访问共享资源”,哪怕所有线程都是读操作,也必须排队等待,这就造成了大量的性能浪费。而我们实际开发中,很多场景都是 “读多写少”:比如服务器接收请求(大多是读取数据)、日志查询、缓存访问等,这些场景下,互斥锁的串行执行会严重拖慢程序效率。
读写锁的出现,就是为了解决 “读多写少” 场景下的性能问题。它的核心优化的是 “读操作的并发”:多个读线程可以同时持有读锁,互不干扰,只有写线程需要独占资源。这样一来,读操作的效率会大幅提升,而写操作的安全性也能得到保证。
举个具体的对比(基于你之前的代码场景):
- 互斥锁:5 个读线程 ×3 次操作 ×100ms = 1500ms(串行执行,实际耗时接近这个值);
- 读写锁:5 个读线程并发执行,3 次操作 ×100ms = 300ms(实际耗时略高于 300ms,因为有锁的切换和线程调度开销)。
差距一目了然 —— 在读写锁的加持下,读密集型场景的性能能提升数倍。这就是我们必须使用读写锁的核心原因:在保证共享资源安全的前提下,最大化读操作的并发效率,解决互斥锁的性能瓶颈。
三、关键疑问:读锁既然是共享的,为什么不直接读,还要加读锁?
这是很多多线程新手最困惑的问题,也是我当初学习读写锁时的疑问。大家会觉得:读操作不会修改数据,多个线程同时读,又不会互相影响,直接读不就行了,何必多此一举加读锁?
答案很简单:读锁的核心作用,不是保护 “读操作本身”,而是保护 “读操作与写操作的同步”,避免出现 “脏读”,同时保证写操作的原子性。
我们举两个极端但真实的场景,就能明白读锁的必要性。
场景 1:读操作与写操作并发,导致脏读
假设我们有一个共享变量 g_data = 100,一个写线程要把 g_data 改成 200,一个读线程要读取 g_data 的值。如果不加读锁,写操作和读操作可能会交叉执行:
- 写线程开始执行,先把 g_data 的高位改成 2(此时 g_data 变成 200,但还没完全写入完成);
- 读线程刚好此时读取 g_data,拿到的是 “未写入完成” 的值(比如 200 的高位已改,低位还没改,可能拿到 200 的中间值);
- 写线程继续执行,完成 g_data 的写入,最终 g_data 变成 200,但读线程已经拿到了错误的 “脏数据”。
有人会说:“写操作不是一瞬间完成的吗?怎么会有中间状态?” 其实不然,在计算机底层,一个简单的赋值操作(比如 int g_data = 200),在多线程环境下可能会被拆分成多个 CPU 指令,并非原子操作。而读锁的作用,就是让读操作 “看到” 的是写操作完成后的完整数据 —— 当有写线程持有写锁时,读线程会被阻塞,直到写线程释放写锁,这样就避免了脏读。
场景 2:多个写线程与读线程并发,导致数据混乱
如果没有读锁,多个写线程和读线程并发访问,不仅会出现脏读,还会导致写操作的原子性被破坏。比如两个写线程同时修改 g_data:
- 写线程 A 要把 g_data 改成 200,写线程 B 要把 g_data 改成 300;
- 读线程 C 在两个写线程执行过程中读取 g_data,可能拿到 200、300,甚至是两者的混合值;
- 更严重的是,两个写线程的操作可能互相覆盖,导致 g_data 的最终值不是预期的 200 或 300,而是一个错误的值。
而读锁的存在,会形成一种 “读写互斥” 的机制:当有读线程持有读锁时,写线程会被阻塞;当有写线程持有写锁时,读线程会被阻塞。这样就保证了 “同一时刻,要么多个读线程并发读,要么一个写线程独占写”,既避免了脏读,也保证了写操作的原子性。
补充:什么时候可以不用读锁?
不是所有读操作都必须加读锁,只有满足以下两个条件时,才能直接读,不用加锁:
- 共享资源是 “只读” 的,从程序启动到结束,没有任何线程会修改它(比如常量);
- 共享资源的修改是 “原子操作”,且读操作不会受到修改过程的影响(这种场景极少,比如用 volatile 修饰的单个基础类型变量,但 volatile 不能替代锁,只能保证可见性,不能保证原子性)。
只要存在 “读 - 写并发” 或 “写 - 写并发”,就必须使用锁 —— 要么用互斥锁,要么用读写锁(读多写少场景优先选读写锁)。
四、读写锁的使用注意事项(避坑指南)
掌握了读写锁的核心逻辑后,还要注意几个细节,避免踩坑:
- 读写锁的 “优先级” 问题:不同系统对读写锁的优先级处理不同,有的是 “读优先”(读线程排队时,写线程会被阻塞,直到所有读线程释放读锁),有的是 “写优先”(写线程排队时,后续的读线程会被阻塞,优先让写线程执行)。如果写操作频繁,读优先可能导致写线程 “饥饿”(一直得不到执行),此时需要手动控制优先级,或改用互斥锁。
- 避免 “锁升级”:读锁不能直接升级为写锁(比如一个线程持有读锁后,想直接加写锁,会导致死锁)。如果需要从读到写,必须先释放读锁,再尝试加写锁。
- 锁的范围要最小化:和所有锁一样,读写锁的加锁和解锁要尽量靠近操作共享资源的代码,避免持有锁的时间过长,影响并发效率。比如你之前代码中,加锁后只执行读操作和模拟耗时,解锁后再休息,这种写法就是正确的。
- 初始化和销毁要成对:动态初始化的读写锁(pthread_rwlock_init),必须在使用完成后用 pthread_rwlock_destroy 销毁,否则会导致资源泄漏。
五、总结:读写锁的核心价值
最后我们用一句话总结读写锁的核心:读写锁是 “读多写少” 场景下的性能最优解,它通过 “读共享、写独占” 的机制,既保证了共享资源的安全性(避免脏读、数据混乱),又最大化了读操作的并发效率,解决了互斥锁串行读的性能瓶颈。
而读锁的存在,不是多余的,而是为了协调读与写的关系 —— 它保护的不是读操作本身,而是读操作的正确性,避免读线程拿到写操作过程中的 “半成品” 数据,同时防止写操作被读操作打断。
结合你之前的代码,我们可以做一个简单的改造:把互斥锁换成读写锁,让 5 个读线程并发执行,就能明显看到性能的提升。下一篇我们可以实战改造代码,对比互斥锁和读写锁的运行效果,更直观地感受读写锁的优势。
c
运行
#include <pthread.h>
// 1. 定义读写锁
pthread_rwlock_t rwlock;
// 2. 初始化读写锁(两种方式)
pthread_rwlock_init(&rwlock, NULL); // 动态初始化
// 或 静态初始化(类似互斥锁的PTHREAD_MUTEX_INITIALIZER)
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
// 3. 加读锁(读线程调用)
pthread_rwlock_rdlock(&rwlock);
// 4. 加写锁(写线程调用)
pthread_rwlock_wrlock(&rwlock);
// 5. 解锁(读锁、写锁解锁用同一个接口)
pthread_rwlock_unlock(&rwlock);
// 6. 销毁读写锁
pthread_rwlock_destroy(&rwlock);
c
运行
#include <pthread.h>
// 1. 定义读写锁
pthread_rwlock_t rwlock;
// 2. 初始化读写锁(两种方式)
pthread_rwlock_init(&rwlock, NULL); // 动态初始化
// 或 静态初始化(类似互斥锁的PTHREAD_MUTEX_INITIALIZER)
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
// 3. 加读锁(读线程调用)
pthread_rwlock_rdlock(&rwlock);
// 4. 加写锁(写线程调用)
pthread_rwlock_wrlock(&rwlock);
// 5. 解锁(读锁、写锁解锁用同一个接口)
pthread_rwlock_unlock(&rwlock);
// 6. 销毁读写锁
pthread_rwlock_destroy(&rwlock);更多推荐



所有评论(0)