mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2237 字
6 分钟
SeqLock 序列锁(SeqLock)
2026-06-13

一、为什么需要 SeqLock#

Linux 内核需要频繁读取系统时钟 ktime_get_ns()。这个值每隔几毫秒就被时钟中断更新一次,而全系统有大量代码在读取它——每个系统调用、每个调度决策、每个定时器检查都要读。用读写锁保护?读端每次 read_lock + read_unlock 至少两次原子操作,在每秒百万次读取的热路径上,这个开销不可忽视。

用 RCU?时钟值是一个 64 位整数,每次更新都复制一份不划算。而且 RCU 的宽限期回收对这种高频小数据更新来说太重了。

SeqLock(Sequence Lock)为这种场景量身定制:写者更新数据时递增一个序列号,读者在读取前后各检查一次序列号——如果序列号没变且为偶数,说明读到了一致的数据;如果变了或为奇数,说明读到了写了一半的数据,重试即可。读者不需要任何锁,只需要读一个整数、检查一下、可能重试。在时钟更新这类「写频繁但写很快、读极多」的场景下,SeqLock 的读端开销几乎为零。

二、现实类比#

想象一个火车站的列车时刻显示屏。屏幕右上角有一个「版本号」,每次工作人员更新时刻表时,版本号就会从偶数变成奇数(表示正在修改),改完后再变成下一个偶数。你看时刻表时,先记下版本号,看完再核对——如果版本号没变且是偶数,说明看到的信息是一致的;如果变了或是奇数,说明刚好赶上更新,再看一遍就行。你不需要锁住屏幕,也不需要通知工作人员,只要多看一眼版本号。

三、核心思想#

SeqLock 由一个序列号(sequence number)和一把自旋锁(spinlock)组成。写者获取自旋锁后递增序列号(变奇数),修改数据,再递增序列号(变偶数)后释放锁。读者不加锁,直接读数据,但要在读前后各读一次序列号。

sequenceDiagram participant W as 写者 participant S as 序列号 participant R as 读者 Note over S: seq = 2(偶数,无写入) R->>S: 读 seq_before = 2 R->>W: 读取数据... W->>S: lock, seq++ → 3(奇数,写入中) W->>W: 修改数据 W->>S: seq++ → 4, unlock(偶数,写入完成) R->>S: 读 seq_after = 4 Note over R: seq_before ≠ seq_after → 重试 R->>S: 读 seq_before = 4 R->>W: 读取数据(一致) R->>S: 读 seq_after = 4 Note over R: seq_before == seq_after 且为偶数 → 成功

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)递增序列号 + 释放自旋锁
Note

SeqLock 的读端是乐观的——它假设大多数时候没有写者,所以直接读然后验证。在写者极少时,读者几乎不会重试,性能接近无锁读取。但写者频繁时,读者可能反复重试,甚至活锁——这是 SeqLock 不适合写密集场景的根本原因。

四、变体与对比#

特性SeqLockRCU读写锁原子操作
读开销极低(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_seqbeginread_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.Int32atomic.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 循环,确保读到一致的 idleusersystem 等计数值。这些值由时钟中断频繁更新,SeqLock 的低读开销完美匹配。

PostgreSQL —— 统计计数器#

PostgreSQL 的统计收集器使用类似 SeqLock 的机制保护共享统计区域。写入统计数据时递增一个计数器,读取时检查计数器是否变化——如果变了就重读。虽然不是标准的 Linux SeqLock API,但核心思路一脉相承:乐观读 + 序列号验证。

七、小结#

什么时候用#

  • 读极多、写少但写很快:时钟、计数器、统计信息等高频读取、低频更新的场景
  • 数据量小:几个字段的结构体,不值得用 RCU 复制整份数据
  • 读者可以容忍重试:偶尔多读一次没关系,不能容忍的是加锁的开销
  • 写者修改是原子的或可容忍短暂不一致:64 位变量在 64 位系统上天然原子,更复杂的数据需要额外注意

什么时候别用#

  • 写者频繁:读者可能反复重试甚至活锁,此时读写锁或 RCU 更合适
  • 数据结构复杂:包含指针的结构体不适合 SeqLock——读者可能跟着半更新的指针访问到无效内存
  • 读者不能重试:如果读者的操作不可重复(如已产生副作用),重试就不安全了
  • 需要写写并行:SeqLock 的写者之间是互斥的,多个写者不能并行

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

SeqLock 序列锁(SeqLock)
https://blog.souloss.com/posts/programming/concurrency/concurrency-seqlock/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时