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 读写锁适用场景分析

  • 适合:路由表查找、配置参数读取、统计信息收集等读操作远多于写操作的场景。
  • 不适合:写操作频繁(写者饥饿)、临界区执行时间长、实时性要求高的系统。
  • 性能特征:读者无竞争时仅需原子递增计数器,开销极低;写者竞争时退化为自旋锁。
读写锁(rwlock)读者-写者模型流程图 锁空闲(计数器=0) 操作类型? 读者 读者计数+1 执行读临界区 读者计数-1 写者 等待读者计数=0 执行写临界区 清除写者标记 锁空闲

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,减少冲突
顺序锁(seqlock)读者-写者时序图 序列号 0 1 2 3 4 写者进入(seq=1) 写者修改数据 写者退出(seq=2) 读者读取 seq=0(偶数) 读者读取数据 读者读取 seq=2(偶数) 读者检测到变化 读者重试 关键点:读者在 seq=0 时开始读取,写者修改后 seq 变为 2,读者检测到变化后重试

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 报告的死锁警告