一、为什么需要 SeqLock
Linux 内核需要频繁读取系统时钟 ktime_get_ns()。这个值每隔几毫秒就被时钟中断更新一次,而全系统有大量代码在读取它——每个系统调用、每个调度决策、每个定时器检查都要读。用读写锁保护?读端每次 read_lock + read_unlock 至少两次原子操作,在每秒百万次读取的热路径上,这个开销不可忽视。
用 RCU?时钟值是一个 64 位整数,每次更新都复制一份不划算。而且 RCU 的宽限期回收对这种高频小数据更新来说太重了。
SeqLock(Sequence Lock)为这种场景量身定制:写者更新数据时递增一个序列号,读者在读取前后各检查一次序列号——如果序列号没变且为偶数,说明读到了一致的数据;如果变了或为奇数,说明读到了写了一半的数据,重试即可。读者不需要任何锁,只需要读一个整数、检查一下、可能重试。在时钟更新这类「写频繁但写很快、读极多」的场景下,SeqLock 的读端开销几乎为零。
二、现实类比
想象一个火车站的列车时刻显示屏。屏幕右上角有一个「版本号」,每次工作人员更新时刻表时,版本号就会从偶数变成奇数(表示正在修改),改完后再变成下一个偶数。你看时刻表时,先记下版本号,看完再核对——如果版本号没变且是偶数,说明看到的信息是一致的;如果变了或是奇数,说明刚好赶上更新,再看一遍就行。你不需要锁住屏幕,也不需要通知工作人员,只要多看一眼版本号。
三、核心思想
SeqLock 由一个序列号(sequence number)和一把自旋锁(spinlock)组成。写者获取自旋锁后递增序列号(变奇数),修改数据,再递增序列号(变偶数)后释放锁。读者不加锁,直接读数据,但要在读前后各读一次序列号。
3.1 奇偶规则
- 偶数:当前没有写者在写,数据是一致的
- 奇数:有写者正在修改,数据可能不一致
读者只需要检查:seq_before == seq_after && seq_before % 2 == 0。如果不满足,重试。
3.2 为什么写者需要自旋锁
SeqLock 的自旋锁不是用来保护读者的——读者不加锁。它是用来保护写者之间的互斥的:同一时刻只能有一个写者在修改数据。两个写者同时改,序列号就乱了。
3.3 核心操作与复杂度
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 读开始(read_seqbegin) | O(1) | 读一次序列号 |
| 读重试判断(read_seqretry) | O(1) | 读一次序列号并比较 |
| 写加锁(write_seqlock) | O(1) | 获取自旋锁 + 递增序列号 |
| 写解锁(write_sequnlock) | O(1) | 递增序列号 + 释放自旋锁 |
SeqLock 的读端是乐观的——它假设大多数时候没有写者,所以直接读然后验证。在写者极少时,读者几乎不会重试,性能接近无锁读取。但写者频繁时,读者可能反复重试,甚至活锁——这是 SeqLock 不适合写密集场景的根本原因。
四、变体与对比
| 特性 | SeqLock | RCU | 读写锁 | 原子操作 |
|---|---|---|---|---|
| 读开销 | 极低(2 次序列号读取) | 零(什么都不做) | 低(原子加锁) | 低(CAS) |
| 写开销 | 低(锁 + 2 次序列号递增) | 高(复制 + 等宽限期) | 中(互斥锁) | 低(CAS 循环) |
| 读者阻塞写者 | 不阻塞 | 不阻塞 | 阻塞 | 不阻塞 |
| 写者阻塞读者 | 不阻塞(读者重试) | 不阻塞 | 阻塞 | 不阻塞(CAS 失败重试) |
| 适用数据大小 | 小(几个字段) | 大(整个结构体/链表) | 任意 | 单个变量 |
| 读者可能重试 | 是 | 否 | 否 | 是(CAS) |
SeqLock vs RCU:两者都允许读者无锁读取,但策略不同。SeqLock 的读者可能读到不一致的数据然后重试,写者原地修改不需要复制。RCU 的读者永远读到一致的数据(要么旧版要么新版),写者必须复制整个数据结构。数据小时用 SeqLock(不需要复制),数据大时用 RCU(复制比读者重试更划算)。
SeqLock vs 原子操作:对于单个 64 位变量在 32 位系统上的读写,原子操作需要 cmpxchg8b 指令,开销不小。SeqLock 只需要普通读写 + 序列号检查,在 32 位系统上读取 64 位时钟值时更高效。Linux 内核正是因此选择 SeqLock 保护 ktime_get_ns()。
4.1 顺序锁与内存屏障
SeqLock 的正确性依赖内存屏障。写者递增序列号后需要 smp_wmb() 确保序列号的写入在数据修改之前对读者可见;读者读数据前后需要 smp_rmb() 确保序列号的读取不被重排到数据读取之前或之后。Linux 内核的 read_seqbegin 和 read_seqretry 已经内置了这些屏障。
五、多语言实现
5.1 Go 实现
package seqlock
import ( "runtime" "sync/atomic")
// SeqLock 用序列号保护共享数据的乐观读type SeqLock struct { seq atomic.Int32 // 序列号,偶数=无写入,奇数=写入中 mu atomic.Bool // 写者互斥锁}
// ReadBegin 返回当前序列号,读者开始读前调用func (sl *SeqLock) ReadBegin() int32 { for { seq := sl.seq.Load() if seq%2 == 0 { return seq // 偶数,可以读 } // 奇数说明有人在写,短暂让出 CPU 后重试 runtime.Gosched() }}
// ReadRetry 检查读到的数据是否一致// 返回 true 表示需要重试func (sl *SeqLock) ReadRetry(seq int32) bool { return sl.seq.Load() != seq // 序列号变了就要重试}
// WriteLock 获取写锁,递增序列号为奇数func (sl *SeqLock) WriteLock() { // 自旋等待其他写者释放 for !sl.mu.CompareAndSwap(false, true) { runtime.Gosched() } sl.seq.Add(1) // 偶数 → 奇数,表示写入中}
// WriteUnlock 递增序列号为偶数,释放写锁func (sl *SeqLock) WriteUnlock() { sl.seq.Add(1) // 奇数 → 偶数,写入完成 sl.mu.Store(false)}
// Read 执行一次乐观读,自动重试直到读到一致数据func Read[T any](sl *SeqLock, readFn func() T) T { for { seq := sl.ReadBegin() val := readFn() // 读取共享数据 if !sl.ReadRetry(seq) { return val // 序列号没变,数据一致 } // 序列号变了,重试 }}Go 版本用 atomic.Int32 和 atomic.Bool 替代内核的自旋锁。Read 泛型函数封装了「读序列号 → 读数据 → 检查序列号 → 重试」的完整流程。注意 readFn 中不能有指针解引用等可能依赖一致性的操作——如果读到半更新的指针,行为是未定义的。
5.2 TypeScript 实现
// SeqLock:用序列号实现乐观读class SeqLock { private seq = 0; // 序列号
// 读开始:返回当前序列号(偶数才可用) readBegin(): number { while (this.seq % 2 !== 0) { // 奇数表示有写入,等一下再试 } return this.seq; }
// 读重试检查:序列号变了就需要重试 readRetry(startSeq: number): boolean { return this.seq !== startSeq; }
// 写加锁:序列号变奇数 writeLock(): void { this.seq++; // 偶数 → 奇数 }
// 写解锁:序列号变偶数 writeUnlock(): void { this.seq++; // 奇数 → 偶数 }}
// 使用示例:保护一个统计数据结构interface Stats { requestCount: number; errorCount: number; avgLatency: number;}
const lock = new SeqLock();let stats: Stats = { requestCount: 0, errorCount: 0, avgLatency: 0 };
// 读者:乐观读,自动重试function readStats(): Stats { for (;;) { const seq = lock.readBegin(); const snapshot = { ...stats }; // 拷贝一份 if (!lock.readRetry(seq)) { return snapshot; // 序列号一致,数据有效 } // 不一致,重试 }}
// 写者:更新统计数据function updateStats(errors: number, latency: number): void { lock.writeLock(); stats.requestCount++; stats.errorCount += errors; stats.avgLatency = (stats.avgLatency + latency) / 2; lock.writeUnlock();}TypeScript 是单线程的(除非用 Worker),上面的 SeqLock 在单线程中更多是概念演示。但在 SharedArrayBuffer + Atomics 的多线程场景中,SeqLock 的思路完全适用——用 Atomics.load 读写序列号,用普通读写操作共享数据。
六、生产验证
Linux 内核 —— 时间子系统
Linux 内核 的时间子系统是 SeqLock 最典型的应用。timekeeper 结构体通过 tk_core.seq 序列锁保护。ktime_get_ns() 调用 read_seqcount_begin 读序列号,读取时钟值,再调用 read_seqcount_retry 检查。时钟中断处理函数 timekeeping_update 调用 write_seqcount_begin/write_seqcount_end 更新时钟。每秒可能有数百万次时钟读取,SeqLock 的零锁开销至关重要。
Linux 内核 —— 文件系统统计
Linux VFS 在 /proc 文件系统中用 SeqLock 保护统计计数器。proc_stat_show 读取 CPU 统计信息时用 read_seqbegin/read_seqretry 循环,确保读到一致的 idle、user、system 等计数值。这些值由时钟中断频繁更新,SeqLock 的低读开销完美匹配。
PostgreSQL —— 统计计数器
PostgreSQL 的统计收集器使用类似 SeqLock 的机制保护共享统计区域。写入统计数据时递增一个计数器,读取时检查计数器是否变化——如果变了就重读。虽然不是标准的 Linux SeqLock API,但核心思路一脉相承:乐观读 + 序列号验证。
七、小结
什么时候用
- 读极多、写少但写很快:时钟、计数器、统计信息等高频读取、低频更新的场景
- 数据量小:几个字段的结构体,不值得用 RCU 复制整份数据
- 读者可以容忍重试:偶尔多读一次没关系,不能容忍的是加锁的开销
- 写者修改是原子的或可容忍短暂不一致:64 位变量在 64 位系统上天然原子,更复杂的数据需要额外注意
什么时候别用
- 写者频繁:读者可能反复重试甚至活锁,此时读写锁或 RCU 更合适
- 数据结构复杂:包含指针的结构体不适合 SeqLock——读者可能跟着半更新的指针访问到无效内存
- 读者不能重试:如果读者的操作不可重复(如已产生副作用),重试就不安全了
- 需要写写并行:SeqLock 的写者之间是互斥的,多个写者不能并行
八、参考资料
- SeqLock Wikipedia - SeqLock 原理与使用约束
- Linux 内核时间子系统 - SeqLock 在时钟更新中的实际应用
- Making RCU Safe for Battery-Powered Devices - McKenney, 2007, RCU vs SeqLock 的选择讨论
- SeqLock 与内存屏障 - Linux 内核文档,内存序对 SeqLock 正确性的影响
- Is Parallel Programming Hard? - McKenney, 2023, SeqLock 专节
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






