单写多读:无锁环形队列为什么敢不用锁
上一篇讲五个经典存储思想时,kfifo 一节带过了一句:单生产者单消费者场景下完全无锁。一句带过其实亏了——这句话背后是一整套可以迁移的并发设计纪律。这篇展开讲:锁到底贵在哪、写者唯一性为什么能消解冲突、"写了不等于看得见"怎么破、以及两个生产者进场后翻车有多快。
1. 锁到底贵在哪
先破一个迷思:无竞争的 mutex 并不慢。Linux 上 pthread mutex 的 fast path 就是一条 CAS 指令,二十纳秒上下。拿掉它的收益没想象中大。
锁真正贵在竞争。一旦有第二个执行者在等,路径立刻变成:获取失败 → 休眠 → 调度器换人 → 唤醒,一来一回微秒级,是 fast path 的百倍。更隐蔽的是缓存行乒乓:锁变量所在的缓存行在多个核之间弹来弹去,谁碰锁谁拖全体后腿。
所以"无锁"的真正目标不是消灭锁这个对象,是 消灭竞争这个状态。SPSC(单生产者单消费者)环形队列是这套思想的最小样例。
2. 核心洞察:给每根指针找一个唯一的写者
把 kfifo 的字段摊开,逐个问"谁写它":
| 字段 | 写者 | 读者 |
|---|---|---|
buf(数据槽位) | 生产者 | 消费者 |
head(写指针) | 生产者 | 消费者 |
tail(读指针) | 消费者 | 生产者 |
每个字段都恰好只有一个写者。这是全部魔法所在:
- 写写冲突不存在了——唯一写者对字段做普通 store,一条指令,没有丢更新的可能
- 不需要 CAS——CAS 解决的是"多个写者抢",写者唯一时抢无从谈起
- 判空判满退化为两个单调计数的比较(
head == tail空、head - tail == cap满),没有回绕歧义
指针的语义约定也随之清晰:写者只推进自己的指针,只读对方的指针。生产者绝不写 tail,消费者绝不写 head。谁越界谁破坏整个协议。
Tip
一句话概括:无锁不是没有并发,是把每个共享字段的写权收敛到唯一执行者,把"抢写"变成"发布 + 观察"。
3. 写了不等于看得见:顺序与可见性
写者唯一只解决了"写不冲突",还有第二关:消费者能不能按 正确的顺序 看到生产者的两次写——先写槽位数据,后推进 head。
生产者的代码直觉上是:
但编译器和 CPU 都可能重排这两步,消费者的核心也可能先看到新的 head、后看到新的数据。结果:消费者以为来了新元素,读到的却是旧值。这不是理论风险,是 x86 之外几乎所有弱内存序架构(ARM、POWER)上的真实行为。
解法是 release/acquire 配对:
- 生产者:写数据 → release 语义 发布
head,保证它之前的写先落地 - 消费者:acquire 语义 读
head,保证之后的读不提前
内核 kfifo 文档的示例就是这套:put 侧 smp_wmb(),get 侧 smp_rmb();现代内核更推荐直接用 smp_store_release() / smp_load_acquire()。Go 里对应 atomic.StoreInt64 / atomic.LoadInt64。最小实现:
type SPSC struct {
buf []entry
head int64 // 生产者独占写
tail int64 // 消费者独占写
}
func (q *SPSC) Push(e entry) bool {
head := q.head // 唯一写者读自己的写,程序序保证可见
if head-atomic.LoadInt64(&q.tail) >= int64(len(q.buf)) {
return false // 满
}
q.buf[head&(int64(len(q.buf))-1)] = e // 先写数据,容量为 2 的幂
atomic.StoreInt64(&q.head, head+1) // 后发布(release)
return true
}
func (q *SPSC) Pop() (entry, bool) {
tail := q.tail
if tail == atomic.LoadInt64(&q.head) { // acquire
return entry{}, false // 空
}
e := q.buf[tail&(int64(len(q.buf))-1)]
atomic.StoreInt64(&q.tail, tail+1) // 释放槽位
return e, true
}
细节都对着上文:head/tail 单调递增、按位与回绕(见 上一篇的 mask 详解);读自己的指针用普通读,读对方的指针必须 atomic。
4. 前提被打破:两个生产者的翻车现场
再来看那句"前提一旦被打破"。两个生产者共享 head,而 head++ 不是一条指令,是读-改-写三步:
时刻 生产者 A 生产者 B
t1 读 head = 5
t2 读 head = 5
t3 写 buf[5] = a
t4 写 buf[5] = b ← 覆盖 a
t5 写 head = 6
t6 写 head = 6 ← 应该是 7
b 覆盖 a,队列少一个元素,静默丢失。没有崩溃,没有错误日志——这是最坏的一类 bug。
出路有三条:
- CAS 循环:
head改用atomic.CompareAndSwap乐观推进,失败重试。队列变成 MPSC,正确性保住了,但高竞争下自旋烧 CPU,吞吐未必赢过锁 - 生产者侧加锁:
Push用 mutex 保护,消费者侧保持无锁。锁的竞争范围被压缩到"生产者内部",多数场景这是性价比最优解——Go channel 的 hchan 干脆一把大锁保平安 - 改结构归并写者:Disruptor 的思路。单生产者模式(
OneToOne)就是纯 SPSC;多生产者用 CAS 认领槽位序列号,或者干脆在业务层把多个生产者归并成一个(Actor 风格),把并发问题消灭在架构层
注意第 3 条的方向感:与其修复被打破的前提,不如重新设计让前提成立。
5. 注意事项
- ⚠️ false sharing:
head和tail若挤在同一个 64 字节缓存行,生产者每次发布head都会打掉消费者核心上tail所在的缓存行,反之亦然——两个"无竞争"的变量互相制造乒乓。解法是填充到独立缓存行,Disruptor 的Sequence左右各垫 7 个 long 就是干这个的 - ⚠️ 32 位平台:64 位变量的原子操作要求 8 字节对齐,Go 在 32 位平台上未对齐直接 panic;C 里则可能撕裂(一个核看到半新半旧)。跨平台代码用
alignas(8)保平安 - ⚠️ 写者唯一指字段,不指队列:
buf的写者也是生产者,两个生产者同时破坏head和buf两份约定;同理 Go 里的"单生产者"指单个 goroutine,业务层 fan-out 到多个 goroutine 就已经破约 - ✅ 排查工具:
go test -race能验证这套纪律——race detector 把 atomic 访问视为同步,读自己指针的普通读不会误报
小结
无锁的完整公式:唯一写者(消灭写冲突)+ release/acquire(保证发布顺序)+ 缓存行隔离(保证不互相拖累)。三者齐备,锁是多余的;缺任何一样,要么丢数据要么性能雪崩。
这套纪律远不止环形队列可用——RCU、seqlock、per-CPU 计数器,全是"唯一写者"这个思想的变体。kfifo 六十年不老,靠的不是环形数组,是从第一天就把并发关系设计成不需要锁的样子。
最后更新:2026-09-07