4. 读写锁(rwlock)与顺序锁(seqlock)
在多核并发编程中,读多写少的场景极为常见。传统的自旋锁或互斥锁在读者之间也强制互斥,导致大量无竞争的读者串行化,严重降低吞吐量。读写锁(rwlock)与顺序锁(seqlock)正是为解决此类场景而设计的轻量级同步原语。本章深入剖析两者的读者-写者模型、实现原理及适用边界。
核心认知:读写锁允许读者并发访问,写者独占;顺序锁则允许写者与读者同时运行,通过序列号检测冲突。两者在实时系统中的适用性截然不同。
4.1 读写锁(rwlock):读者-写者模型
读写锁区分两种访问模式:读者(Reader) 和 写者(Writer)。多个读者可以同时持有锁;写者必须等待所有读者和写者释放锁后才能进入临界区。
4.1.1 读者-写者模型语义
| 操作 | 读者行为 | 写者行为 |
|---|---|---|
| 锁空闲 | 直接获取,读者计数+1 | 直接获取,标记写者占用 |
| 有读者持有 | 直接获取,读者计数+1 | 自旋等待,直到读者计数归零 |
| 有写者持有 | 自旋等待,直到写者释放 | 自旋等待,直到写者释放 |
| 释放锁 | 读者计数-1 | 清除写者标记 |
实时系统提示:读写锁在读者持有锁期间,写者会陷入不可预测的自旋等待。若读者被高优先级任务抢占,写者的等待时间可能无限延长,导致优先级反转。因此,PREEMPT_RT 内核默认将读写锁转换为互斥锁,以支持优先级继承。
4.1.2 内核 API 与使用示例
Linux 内核提供两套读写锁接口:经典 rwlock_t 和实时友好的 rwlock_t(在 PREEMPT_RT 下退化为互斥锁)。
#include <linux/rwlock.h>
rwlock_t my_rwlock;
// 读者路径
read_lock(&my_rwlock);
/* 只读临界区:读取共享数据 */
read_unlock(&my_rwlock);
// 写者路径
write_lock(&my_rwlock);
/* 读写临界区:修改共享数据 */
write_unlock(&my_rwlock);
避坑指南:在读者临界区内绝对不能尝试获取写锁,否则会导致死锁。同样,写者持有锁期间也不应再获取读锁。读写锁不支持递归获取。
4.1.3 读写锁适用场景分析
- 适合:路由表查找、配置参数读取、统计信息收集等读操作远多于写操作的场景。
- 不适合:写操作频繁(写者饥饿)、临界区执行时间长、实时性要求高的系统。
- 性能特征:读者无竞争时仅需原子递增计数器,开销极低;写者竞争时退化为自旋锁。
4.2 顺序锁(seqlock):轻量级实现
顺序锁是一种写者优先的同步机制,其核心思想是:写者无需等待读者,直接修改数据;读者通过检查序列号判断读取过程中是否发生了写操作。若序列号发生变化,读者必须重试。
4.2.1 工作原理
顺序锁维护一个单调递增的序列号(sequence counter):
- 写者:先递增序列号(奇数表示写者正在修改),执行写操作,再递增序列号(恢复为偶数)。
- 读者:读取序列号(偶数时有效),执行读操作,再次读取序列号。若两次读取的序列号相同且为偶数,则读取成功;否则重试。
// 顺序锁典型实现(伪代码)
struct seqlock {
atomic_t seq; // 序列号,初始为0
};
void write_seqlock(struct seqlock *sl) {
atomic_inc(&sl->seq); // 变为奇数,标记写者进入
smp_mb(); // 内存屏障,确保后续写操作可见
}
void write_sequnlock(struct seqlock *sl) {
smp_mb(); // 内存屏障,确保写操作完成
atomic_inc(&sl->seq); // 变为偶数,标记写者退出
}
unsigned read_seqbegin(const struct seqlock *sl) {
unsigned seq = atomic_read(&sl->seq);
smp_rmb(); // 读内存屏障
return seq;
}
int read_seqretry(const struct seqlock *sl, unsigned start) {
smp_rmb(); // 读内存屏障
return atomic_read(&sl->seq) != start; // 序列号变化则重试
}
4.2.2 读者重试循环模式
// 读者必须使用 do-while 循环保证数据一致性
do {
seq = read_seqbegin(&my_seqlock);
/* 读取共享数据 */
value1 = shared_data.a;
value2 = shared_data.b;
} while (read_seqretry(&my_seqlock, seq));
性能优势:读者在无写者竞争时,仅需两次原子读取序列号,无自旋等待,开销极低。写者不会被读者阻塞,适合写操作偶发但需要低延迟的场景。
4.2.3 顺序锁适用场景分析
| 场景特征 | 推荐程度 | 原因 |
|---|---|---|
| 读多写少,写操作极少 | ★★★★★ | 读者几乎从不重试,性能接近无锁 |
| 写操作频繁 | ★★☆☆☆ | 读者频繁重试,可能导致活锁 |
| 读者临界区很大 | ★★☆☆☆ | 写者等待读者完成当前重试周期,延迟增加 |
| 实时系统(PREEMPT_RT) | ★★★☆☆ | 读者无阻塞,但写者可能被读者抢占导致延迟 |
| 需要保护多个独立数据 | ★★★★☆ | 每个数据域可独立使用 seqlock,减少冲突 |
4.3 读写锁 vs 顺序锁:对比与选型
| 对比维度 | 读写锁(rwlock) | 顺序锁(seqlock) |
|---|---|---|
| 读者并发 | 完全并发,无竞争 | 完全并发,无竞争 |
| 写者阻塞 | 被所有读者阻塞 | 不被读者阻塞 |
| 读者阻塞 | 被写者阻塞(自旋) | 从不阻塞(可能重试) |
| 写者饥饿 | 可能(读者持续进入) | 不会(写者优先) |
| 读者开销(无竞争) | 原子递增/递减 | 两次原子读取 |
| 读者开销(有写者) | 自旋等待 | 重试循环 |
| 实时安全性 | 差(优先级反转) | 中等(读者无阻塞) |
| 适用数据量 | 小到中等 | 小到中等 |
| 典型用途 | 路由表、配置缓存 | 时间戳、统计计数器、RCU 辅助 |
实时内核特殊处理:在
PREEMPT_RT 内核中,rwlock_t 被替换为基于 rt_mutex 的实现,支持优先级继承。而 seqlock 在读者路径上仍然是非阻塞的,但写者路径可能被抢占,因此实时性优于读写锁,但仍需谨慎评估写者延迟。
4.4 故障排查要点
- 读写锁死锁:检查是否在读者临界区内获取写锁,或写者临界区内获取读锁。使用
lockdep可自动检测此类问题。 - 写者饥饿:若读者持续持有锁,写者可能无限等待。考虑使用
seqlock或RCU替代。 - 顺序锁活锁:写者过于频繁导致读者永远无法成功读取。监控
/proc/lock_stat中的重试次数,若重试率过高,需降低写频率或拆分数据域。 - 内存顺序问题:seqlock 依赖内存屏障保证可见性。若自定义实现,务必包含
smp_mb()或smp_rmb(),否则在弱排序架构(如 ARM)上可能读到不一致数据。
调试命令速查:
# cat /proc/lock_stat — 查看锁竞争统计# echo 1 > /proc/sys/kernel/lock_stat — 启用锁统计# dmesg | grep lockdep — 检查 lockdep 报告的死锁警告