[toc]
include/linux/seqlock.h 顺序锁(Sequence Lock) 针对读多写少场景的无锁读者优化
历史与背景
这项技术是为了解决什么特定问题而诞生的?
seqlock(顺序锁)是一种高度特化的同步原语,它的诞生是为了解决一个在传统读写锁(rw_lock)中存在的经典性能问题:写者饥饿(Writer Starvation)。
在标准的读写锁模型中,当一个或多个读者持有读锁时,任何试图获取写锁的写者都必须等待。如果读者频繁地、接连不断地到来,那么写者可能会被无限期地延迟,即“饿死”。这在某些读操作极其频繁、写操作较少的场景下会成为严重的性能瓶颈。
seqlock通过颠覆这个模型来解决问题:它赋予写者绝对的优先权。一个写者永远不会被读者阻塞,它可以随时进入临界区并修改数据,即使当时有读者正在读取。为了在这种“不加保护”的读取中保证数据的一致性,它引入了一个序列计数器,让读者自己来检测在读取期间是否有写者介入,并在检测到冲突时进行重试。
它的发展经历了哪些重要的里程碑或版本迭代?
- 作为一种优化模式被引入:
seqlock不是一个通用的锁,而是作为一种针对特定场景的性能优化被引入内核的。其核心思想——使用序列计数器来验证读取的原子性——是其最重要的设计里程碑。 - 在时间子系统中的应用:
seqlock最经典、最成功的应用是在内核的时间管理子系统中。系统中有大量的代码需要读取当前时间(如jiffies、xtime),而只有一个地方(时钟中断)会更新它。使用seqlock保护时间变量,使得无数的读者可以极快地、无锁地获取时间,而唯一的写者(时钟中断)也几乎不受影响。 seqcount_t的拆分:后来,内核将seqlock的核心机制——序列计数器——单独拆分出来,形成了一个更底层的原语seqcount_t。这允许开发者在更复杂的锁结构中只使用其计数器和验证逻辑,而将写者之间的互斥逻辑交由其他锁(如自旋锁或互斥锁)来管理,提供了更大的灵活性。
目前该技术的社区活跃度和主流应用情况如何?
seqlock是一个非常成熟和稳定的内核同步机制。由于其高度特化,它的代码本身不经常变动,但它在内核中一些对性能极其敏感的核心路径上被广泛使用。
- 内核时间管理:保护
jiffies_64和xtime_lock等。 - 调度器:用于保护某些调度器统计信息。
- 其他读多写少的场景:例如,系统V IPC的某些状态变量。
它被认为是内核工具箱中一个重要的、但需要谨慎使用的专家级工具。
核心原理与设计
它的核心工作原理是什么?
seqlock的核心是一个序列计数器(sequence counter)和一个用于保护写者之间互斥的自旋锁(spinlock)。
数据结构 (
seqlock_t):- 一个
seqcount_t类型的序列计数器。 - 一个
spinlock_t类型的自旋锁。
- 一个
写者(Writer)的流程 (
write_seqlock/write_sequnlock):
a. 获取自旋锁,防止其他写者进入。
b. 将序列计数器加一。此时,计数器变为奇数,这是一个明确的“正在写入”信号。
c. 修改被保护的数据。
d. 再次将序列计数器加一。此时,计数器恢复为偶数。
e. 释放自旋锁。读者(Reader)的流程 (
read_seqbegin/read_seqretry):
a. 进入一个循环。
b. 调用read_seqbegin(),读取并保存当前序列号(我们称之为start_seq)。
c. 检查奇偶性:如果start_seq是奇数,说明此时有写者正在修改数据,读者必须自旋等待,直到它变为偶数。(这是通过read_seqbegin_or_lock等更现代的接口实现的优化)。
d. 读取被保护的数据副本。
e. 调用read_seqretry(),它会再次读取序列号(我们称之为end_seq)。
f. 验证:比较start_seq和end_seq。如果start_seq != end_seq或者start_seq是奇数,说明在读者读取数据的过程中,一个写者进入并完成了修改。读者刚才读取的数据是无效的、可能被部分修改过的。
g. 如果验证失败,循环从头开始,重新读取。如果验证成功,说明读取过程没有被打扰,数据是一致的,循环结束。
它的主要优势体现在哪些方面?
- 写者无饥饿:写者永远不会等待读者,这是其核心设计目标。
- 极快的读者路径:在没有写者竞争的理想情况下,读者路径是完全无锁的。它只涉及几次内存读取操作(读计数器和数据),不会导致不同CPU之间的缓存行争用,性能极高。
- 低开销:锁本身的数据结构非常小。
它存在哪些已知的劣势、局限性或在特定场景下的不适用性?
- 读者可能重试:如果写操作频繁或耗时较长,读者可能会多次重试,导致读者路径的性能急剧下降。因此,它只适用于写操作非常少且非常快的场景。
- 被保护的数据不能包含指针:这是
seqlock最重要、最危险的限制。因为读者读取数据和验证数据有效性是分离的。读者可能读取到一个指针,然后被抢占;此时写者介入,修改了数据并释放了该指针指向的内存;读者恢复执行,它通过序列号检查发现数据无效,但它手中已经持有一个悬空指针,如果后续代码不小心使用了这个指针,将导致Use-After-Free漏洞。因此,seqlock只能用于保护那些可以安全地按值复制的、不含指针的简单数据(如整数、不含指针的结构体)。
使用场景
在哪些具体的业务或技术场景下,它是首选解决方案?
seqlock是**“读多、写极少、写极快、数据为纯值”**场景下的性能优化利器。
- 内核时间变量:如上所述,这是最完美的例子。只有一个写者(时钟中断),写操作极快(更新一个64位整数),有无数的读者,且数据是纯值。
- 系统状态标志或计数器:某个子系统有一组状态变量,由该子系统内部的少数几个点进行快速更新,而内核的其他很多地方都需要读取这些状态。
是否有不推荐使用该技术的场景?为什么?
- 写操作频繁或耗时:会造成读者性能严重下降,此时标准的
rw_lock或mutex是更好的选择。 - 保护含有指针的数据结构:绝对禁止,原因如上所述。应使用
rw_lock或RCU(Read-Copy-Update)。 - 通用的驱动程序锁:对于大多数设备驱动程序,其临界区保护逻辑不符合
seqlock的苛刻前提。开发者应默认使用更安全、更通用的mutex或spinlock。
对比分析
请将其 与 其他相似技术 进行详细对比。
| 特性 | Seqlock | RCU (Read-Copy-Update) | 读写锁 (RW_Lock) | 自旋锁/互斥锁 (Spinlock/Mutex) |
|---|---|---|---|---|
| 写者饥饿 | 不会 | 不会 | 会 (读者优先版本) | 不适用 (只有一个持有者) |
| 读者开销(无争用) | 极低 (无锁,几次内存读) | 极低 (无锁,无原子操作) | 中等 (有原子操作) | 中等/高 (有原子操作/可能睡眠) |
| 读者行为(有争用) | 自旋重试 (可能性能差) | 无阻塞 (可看到旧数据) | 阻塞 (自旋或睡眠) | 阻塞 (自旋或睡眠) |
| 数据约束 | 严格 (不能含指针) | 严格 (指针管理复杂,需优雅期) | 无 | 无 |
| 写者开销 | 低 (一个自旋锁) | 高 (复制数据结构,管理回调) | 中等 (原子操作,可能阻塞) | 中等/高 |
| 能否在读者中睡眠 | 否 | 是 | 否 (spinlock版) / 是 (semaphore版) | 否 (spinlock) / 是 (mutex) |
| 核心思想 | 读者乐观读取,事后验证 | 写者创建新副本,延迟释放旧副本 | 读者之间共享,读者与写者互斥 | 完全互斥 |
seqcount_latch 为什么要这样设计,而不是直接用 seqlock_t 的奇偶序列号方案
在 ARMv7-M 上,u64 的读写通常要拆成两次 32 位访问。只靠“读前后序列号一致”并不能解决“读者在写者正在写同一份数据时读到撕裂值”的风险,除非你让读者在写入期间完全不去读那份数据(要么阻塞等待,要么读另一份副本)。
1) seqlock_t 的方案“能不能用”
你描述的 seqlock 方案在一个前提下是成立的:
- 写者必须被互斥串行化(典型是
seqlock_t内含spinlock_t,或者写者自行持有外部锁)。 - 读者通过
read_seqbegin/read_seqretry反复重读,最终拿到一致快照。
它的问题不在“原理错”,而在于它对读者的隐含要求:
- 当序列号是奇数(写入中)时,读者要等待/自旋/重试,直到写者结束写入。
- 这对“读者允许等待”的上下文通常没问题;但对“读者不能等待或不应等待太久”的上下文就不合适。
你这里的 latched_seq_read_nolock() 明确写着“任意上下文可调用”,这类约束通常意味着:
- 读者可能来自中断/NMI/日志路径等,不希望因为写者正在更新就陷入长时间自旋;
- 甚至某些路径里,等待会放大系统抖动或导致连锁问题。
所以 latch 的目标不是“比 seqlock 更通用”,而是“读者在写入期间也尽量一次读成功,并且不需要等待写者退出写区”。
2) seqcount_latch 的核心思想:双副本 + 读者重定向
seqcount_latch 配合 val[2] 的关键不变量是:
- 写者永远只写“读者当前不会去读”的那一份副本;
- 写完一份后,通过更新
sequence的最低位,把读者切换到那份已写完的副本; - 然后写者再去写另一份副本。
对应到你看到的写流程:
write_seqcount_latch_begin():把读者引导去读 副本 1(最低位变成 1)- 写
val[0] - 中间这句
write_seqcount_latch():把读者切回去读副本 0(最低位变成 0) - 写
val[1] write_seqcount_latch_end()
如果没有中间那次切换,读者就会一直被引导去读同一份副本,而写者后半段又去写那份副本,于是读者就可能在 u64 拆写期间读到撕裂值,并且还可能因为序列号没有发生“足以让读者判定需要重试的变化”而误以为读到了稳定值。
一句话概括:
中间那次 write_seqcount_latch() 的作用就是“完成副本 0 后,把读者切到副本 0,再去更新副本 1”,从结构上保证读者读到的那份副本在该阶段不被写。
3) 你贴的 raw_write_seqcount_latch 为什么要两个 smp_wmb
1 | smp_wmb(); |
这里不是“为了好看”,而是为了给读者建立一个可靠的“相位边界”:
第一个写屏障:保证在推进
sequence之前,写者对上一阶段数据的写入不会被重排到sequence++之后。
否则读者可能先看到“相位已经切换”,但相位对应的那份数据实际上还没写完。第二个写屏障:保证
sequence++对外可见后,后续对另一份副本的写入不会被重排到sequence++之前。
否则读者可能在新相位下读到“本应稳定的副本”,但写者对另一副本的写入却被重排影响到可见性关系,增加读到中间态的概率。
再结合读侧:
- 读侧先
READ_ONCE(sequence)决定idx,读val[idx]; - 最后
smp_rmb()+ 再读一次sequence做一致性校验,确保“读出来的值确实属于同一个相位”。
即使在单核上,这些屏障在很多配置下会被弱化成编译器屏障或最小指令序列,但它们表达的是跨架构通用的可见性/重排约束,让这套模式在更弱内存序的平台也正确。
4) 你担心“多个写者会让奇偶判断失效”怎么处理
你的担心只在“多个写者并发进入写区且不互斥”时成立,但这点对两种方案都一样:
- seqlock:需要
spinlock(或外部锁)保证写者串行;否则两个写者交错修改数据,读者即便重试也可能持续读到混合态。 - seqcount_latch:同样要求写者外部串行(你原例里写明“必须在 syslog_lock 下调用”)。如果两个写者同时修改
val[0]/val[1],双副本也会被交错污染,不变量被破坏,读者同样无法保证一致性。
所以“奇偶性会不会失效”的正确表述是:
奇偶性不是用来解决多写者并发的;多写者并发必须用锁或其它机制先串行化。序列号机制解决的是“读者如何检测并发写入并重试”,不是“多个写者如何互斥”。
5) “保护大量数据写入是不是很别扭”
是的,这是 latch 的代价,且是明确的设计取舍:
latch 要求维护两份数据,并且每次写要写两份(写放大、空间翻倍)。
因此它更适合:
- 数据结构相对小;
- 读路径要求在写入期间也尽量立即返回(而不是等待写者结束);
- 需要覆盖“非原子更新”(例如 32 位平台上的
u64、或更复杂的非原子字段组合)。
如果数据很大,常见替代思路是:
- 用 seqlock/seqcount 保护“可原子发布的指针”,写者构造新版本后一次性发布指针(读者读指针后只读不可变快照);
- 或用 RCU 管理对象生命周期与发布(更适合动态结构),读者不等待写者。
这些方案的共同点是:避免“每次写都复制整份大数据两次”。
seqcount_latch_t seqcount_t
1 | /* |
write_seqlock 写入序列锁加锁
1 | static inline void do_raw_write_seqcount_begin(seqcount_t *s) |
write_sequnlock 写入序列锁解锁
1 | static inline void do_raw_write_seqcount_end(seqcount_t *s) |
__seqprop
- 利用了 C11 标准中的
_Generic关键字来实现类型泛型编程。类型泛型允许根据表达式的类型选择不同的代码路径,从而实现类似于函数重载的功能。 - _Generic 是 C11 中引入的一个关键字,用于实现类型分发。它的语法类似于一个类型匹配的 switch 语句。_Generic(*(s), …) 会根据 *(s) 的类型(即 s 所指向的对象的类型)选择对应的分支。
1 |
read_seqbegin 读取序列锁开始
- 计数为奇数,证明写入端正在进行.读者需要重新读取.
1 | static inline unsigned __seqprop_sequence(const seqcount_t *s) |
read_seqretry 读取序列锁结束
- 返回读取时的计数.如果读取时的计数和当前计数不一致,则返回 true,需要重新读取.
1 | static inline int do___read_seqcount_retry(const seqcount_t *s, unsigned start) |
seqcount_raw_spinlock_init Seqcount 原始自旋锁初始化
1 | static inline void __seqcount_init(seqcount_t *s, const char *name, |
read_seqcount_latch_retry 结束 seqcount_latch_t 阅读部分
1 | /** |
raw_write_seqcount_latch 将锁存读取器重定向到偶数/奇数副本
1 | /** |
write_seqcount_latch_begin 将 Latch 读取器重定向到 奇数副本
1 | /** |
write_seqcount_latch 重定向 Latch Reader 以均匀复制
1 | /** |
write_seqcount_latch_end 结束 seqcount_latch_t 写入部分
1 | /** |









