第4章 读写锁与共享锁:读读不互斥、读写互斥、写写互斥
在并发访问共享资源时,锁的互斥粒度直接决定了系统的吞吐能力与数据安全性。读写锁(Readers-Writer Lock)是对传统互斥锁的重要优化,它基于访问类型而非线程身份进行控制,提供三种核心语义:读读不互斥、读写互斥、写写互斥。本章从架构层面拆解这些语义如何影响模块边界、组件职责与调度路径。
| 互斥关系 | 并发能力 | 数据安全压力 | 典型场景 |
|---|---|---|---|
| 读读不互斥 | 完全并发 | 低(只读) | 缓存读取、配置查询 |
| 读写互斥 | 完全串行 | 中(写可能看到旧读) | 读写混合的高频查询 |
| 写写互斥 | 完全串行 | 高(写写冲突) | 计数器更新、状态变更 |
架构启示: 读写锁的本质是将“访问类型”作为关注点分离的杠杆,在读多写少的边界处,它比互斥锁提升 2~10 倍的并发能力,但代价是写操作的公平性和饥饿风险。
模块边界
读写锁的边界应划定在读多写少的组件交界处。模块 A 提供只读查询接口,模块 B 提供状态更新接口,两者通过同一读写锁共享数据容器。边界处只暴露锁的读/写获取方法,不暴露内部互斥策略。
- 读边界:允许任意数量并发读,无数据竞争。
- 写边界:确保写操作排它,写完成后读马上可见。
- 边界契约:读期间禁止写,写期间禁止读写。
组件职责
- 读计数器:记录当前活跃读线程数量;在锁管理器中内聚。
- 写标志位:指示是否有写线程持有锁;写线程退出时清零。
- 等待队列:分别维护读等待与写等待队列,避免线程轮询。
- 锁管理器:根据当前状态决定新请求是直接放行、排队还是唤醒。
数据流与控制流
读请求数据流: 读线程调用 acquire_read → 锁管理器检查写标志位为空 → 读计数器递增 → 直接访问共享资源 → release_read → 读计数器递减 → 若为 0 且写队列非空,则唤醒一个写线程。
写请求控制流: 写线程调用 acquire_write → 锁管理器检查读计数器=0 且写标志位=0 → 写标志位置位 → 排它访问共享资源 → release_write → 写标志位清零 → 按策略唤醒读或写等待队列。
关键路径: 读读不互斥使得读请求完全并行,控制流只需 O(1) 检查标志位;读写互斥控制流需要挂起/唤醒操作,上下文切换代价较高。
选型权衡
| 维度 | 读写锁 | 细粒度锁 |
|---|---|---|
| 写公平性 | 可能写饥饿(读源源不断) | 均等竞争 |
| 读吞吐量 | 极高(并行读) | 中等(分区竞争) |
| 实现复杂度 | 低(标准库内置) | 高(需要分区算法) |
| 适用场景 | 读远大于写 | 读写比例均衡 |
风险点:
- 写优先级反转: 当读线程持续进入时,写线程可能无限期等待;需使用公平策略或写偏爱锁。
- 读锁升级: 在持有读锁时试图获取写锁,极易造成死锁;架构层禁止此操作。
- 锁降级: 写锁释放前应降级为读锁以降低调度开销,但需确保事务原子性。
落地步骤
- 识别读多写少的模块边界(如配置中心、元数据缓存)。
- 选择语言标准库的读写锁实现(如
std::shared_mutex、pthread_rwlock)。 - 封装锁接口为显式读/写操作,避免调用方直接操作锁对象。
- 设置写偏爱或读偏爱策略,根据业务对公平性的要求调整。
- 压测对比互斥锁,验证读并发提升是否达到期望。
验收标准
- 并发 10 个读线程时,延迟低于 2 ms,且无写线程明显阻塞。
- 写操作期间,所有读线程必须全部阻塞(可接受的最大延迟小于 5 ms)。
- 连续 1000 次写后,数据一致性与无锁场景一致。
- 锁管理器无死锁、无饥饿报告(运行 24 h 无异常)。
验证手段: 使用线程安全分析工具(如 ThreadSanitizer)检测数据竞争;通过火焰图观察读路径是否并行化。