跳转至

单写多读:无锁环形队列为什么敢不用锁

上一篇讲五个经典存储思想时,kfifo 一节带过了一句:单生产者单消费者场景下完全无锁。一句带过其实亏了——这句话背后是一整套可以迁移的并发设计纪律。这篇展开讲:锁到底贵在哪、写者唯一性为什么能消解冲突、"写了不等于看得见"怎么破、以及两个生产者进场后翻车有多快。

1. 锁到底贵在哪

先破一个迷思:无竞争的 mutex 并不慢。Linux 上 pthread mutex 的 fast path 就是一条 CAS 指令,二十纳秒上下。拿掉它的收益没想象中大。

锁真正贵在竞争。一旦有第二个执行者在等,路径立刻变成:获取失败 → 休眠 → 调度器换人 → 唤醒,一来一回微秒级,是 fast path 的百倍。更隐蔽的是缓存行乒乓:锁变量所在的缓存行在多个核之间弹来弹去,谁碰锁谁拖全体后腿。

所以"无锁"的真正目标不是消灭锁这个对象,是 消灭竞争这个状态。SPSC(单生产者单消费者)环形队列是这套思想的最小样例。

2. 核心洞察:给每根指针找一个唯一的写者

把 kfifo 的字段摊开,逐个问"谁写它":

字段 写者 读者
buf(数据槽位) 生产者 消费者
head(写指针) 生产者 消费者
tail(读指针) 消费者 生产者

每个字段都恰好只有一个写者。这是全部魔法所在:

  1. 写写冲突不存在了——唯一写者对字段做普通 store,一条指令,没有丢更新的可能
  2. 不需要 CAS——CAS 解决的是"多个写者抢",写者唯一时抢无从谈起
  3. 判空判满退化为两个单调计数的比较(head == tail 空、head - tail == cap 满),没有回绕歧义

指针的语义约定也随之清晰:写者只推进自己的指针,只读对方的指针。生产者绝不写 tail,消费者绝不写 head。谁越界谁破坏整个协议。

Tip

一句话概括:无锁不是没有并发,是把每个共享字段的写权收敛到唯一执行者,把"抢写"变成"发布 + 观察"。

3. 写了不等于看得见:顺序与可见性

写者唯一只解决了"写不冲突",还有第二关:消费者能不能按 正确的顺序 看到生产者的两次写——先写槽位数据,后推进 head

生产者的代码直觉上是:

q.buf[i] = e        // 先写数据
q.head = head + 1   // 后发布

但编译器和 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。

出路有三条:

  1. CAS 循环head 改用 atomic.CompareAndSwap 乐观推进,失败重试。队列变成 MPSC,正确性保住了,但高竞争下自旋烧 CPU,吞吐未必赢过锁
  2. 生产者侧加锁Push 用 mutex 保护,消费者侧保持无锁。锁的竞争范围被压缩到"生产者内部",多数场景这是性价比最优解——Go channel 的 hchan 干脆一把大锁保平安
  3. 改结构归并写者:Disruptor 的思路。单生产者模式(OneToOne)就是纯 SPSC;多生产者用 CAS 认领槽位序列号,或者干脆在业务层把多个生产者归并成一个(Actor 风格),把并发问题消灭在架构层

注意第 3 条的方向感:与其修复被打破的前提,不如重新设计让前提成立

5. 注意事项

  • ⚠️ false sharingheadtail 若挤在同一个 64 字节缓存行,生产者每次发布 head 都会打掉消费者核心上 tail 所在的缓存行,反之亦然——两个"无竞争"的变量互相制造乒乓。解法是填充到独立缓存行,Disruptor 的 Sequence 左右各垫 7 个 long 就是干这个的
  • ⚠️ 32 位平台:64 位变量的原子操作要求 8 字节对齐,Go 在 32 位平台上未对齐直接 panic;C 里则可能撕裂(一个核看到半新半旧)。跨平台代码用 alignas(8) 保平安
  • ⚠️ 写者唯一指字段,不指队列buf 的写者也是生产者,两个生产者同时破坏 headbuf 两份约定;同理 Go 里的"单生产者"指单个 goroutine,业务层 fan-out 到多个 goroutine 就已经破约
  • ✅ 排查工具:go test -race 能验证这套纪律——race detector 把 atomic 访问视为同步,读自己指针的普通读不会误报

小结

无锁的完整公式:唯一写者(消灭写冲突)+ release/acquire(保证发布顺序)+ 缓存行隔离(保证不互相拖累)。三者齐备,锁是多余的;缺任何一样,要么丢数据要么性能雪崩。

这套纪律远不止环形队列可用——RCU、seqlock、per-CPU 计数器,全是"唯一写者"这个思想的变体。kfifo 六十年不老,靠的不是环形数组,是从第一天就把并发关系设计成不需要锁的样子。


最后更新:2026-09-07

评论