跳转至

两个生产者之后:给无锁环形队列加同步的三条路

前作讲 kfifo 时留了一句狠话:

前作:一个没有名字的持久化设计

每根指针都满足"单写多读",配合内存屏障保证可见性,锁就是多余的。这个前提一旦被打破(两个生产者),无锁立刻失效,必须自己加同步。

上一篇把翻车现场拍成了慢动作——head++ 是读-改-写三步,两个生产者同读到 head=5,一个元素静默丢失。这篇接着走下一步:怎么修。原文给了三条出路的目录但没给代码,这里补全。

1. 要修的到底是什么

先把竞争拆开。两个生产者进场,打碎的其实只是一个动作:认领槽位(谁拿到 head=5、谁该拿 6)。而"数据先于指针可见"的发布顺序问题,本身并没有坏——只是没人替它做担保了。

所以修复目标一句话:把"认领"重新变成原子操作,同时保住"发布"的顺序语义

这两件事正交,混在一起谈就会觉得"加同步"是一团浆糊。三条路线,本质上就是用三种方式回答这两个问题:乐观地重试(CAS)、悲观地排队(mutex)、干脆改结构让前提复活(per-slot 状态机 / 归并写者)。

2. 路线一:CAS 认领 + 提交计数

直觉方案是给 head++ 换成 CAS 循环。但只做这一步有个隐蔽的坑:CAS 成功的瞬间 head 已经 +1,而数据还没写进槽位——消费者看 head 前进了,立刻去读,读到空槽。CAS 只修了"抢",没修"序"

正确做法是把 head 的职责拆成两个计数器:

  • head认领边界,生产者用 CAS 抢占,抢到即锁定槽位号
  • committed发布边界,只有"轮到自己"的生产者才能推进
type MPSC struct {
    buf       []entry
    head      int64 // 认领边界:生产者 CAS 抢
    committed int64 // 发布边界:轮到的生产者推进
    tail      int64 // 消费者独占写
}

func (q *MPSC) Push(e entry) bool {
    for {
        head := atomic.LoadInt64(&q.head)
        if head-atomic.LoadInt64(&q.tail) >= int64(len(q.buf)) {
            return false // 满:认领前退出,无副作用
        }
        // 认领:只有一个生产者能打成功,槽位号 = head
        if !atomic.CompareAndSwapInt64(&q.head, head, head+1) {
            continue // 被抢了,重读重试
        }
        q.buf[head&(int64(len(q.buf))-1)] = e // 槽位已归我,放心写
        for atomic.LoadInt64(&q.committed) != head {
            runtime.Gosched() // 排队交卷:等前面的生产者先发布
        }
        atomic.StoreInt64(&q.committed, head+1) // release:数据先落地,边界后推进
        return true
    }
}

func (q *MPSC) Pop() (entry, bool) {
    tail := q.tail
    if tail == atomic.LoadInt64(&q.committed) { // acquire:与 release 配对
        return entry{}, false // 空
    }
    e := q.buf[tail&(int64(len(q.buf))-1)]
    atomic.StoreInt64(&q.tail, tail+1) // 释放槽位
    return e, true
}

正确性逐条对上:

  1. 每个成功的 CAS 拿到唯一槽位号,两个生产者写同一格的事故根除
  2. 消费者只看 committed,认领了但没交卷的槽位它读不到——空槽问题根除
  3. 消费者读完数据才推进 tail,而下一圈认领者(pos + cap)必须等 tail 越过 pos,覆盖时旧值必已被读走

代价也明摆着:交卷要排队。认领了 5 号槽的生产者要是被调度器晾在一边,6 号、7 号全堵着交不了卷。这是 FIFO 语义的固有代价——区别只是堵的位置从消费侧挪到了发布侧。

Tip

认领解决"谁写哪里",发布解决"什么时候可见"。多生产者下这是两个独立的问题,一套 CAS 只够修第一个。

3. 路线二:生产者侧加锁

回头看一眼 mutex,会发现它没那么土。锁的作用不是保护整个队列,而是 临时恢复 head 的唯一写者——同一时刻只有持锁的那个生产者有写权,SPSC 的全部纪律原样复活:

type MPSCMutex struct {
    mu   sync.Mutex // 只罩生产者
    buf  []entry
    head int64 // 生产者写(持锁),消费者读
    tail int64 // 消费者独占写
}

func (q *MPSCMutex) Push(e entry) bool {
    q.mu.Lock()
    defer q.mu.Unlock()
    if q.head-atomic.LoadInt64(&q.tail) >= int64(len(q.buf)) {
        return false // 满
    }
    q.buf[q.head&(int64(len(q.buf))-1)] = e
    atomic.StoreInt64(&q.head, q.head+1) // 锁保写权,atomic 保发布
    return true
}

func (q *MPSCMutex) Pop() (entry, bool) {
    // 与 SPSC 版完全一致:tail 仍是唯一写者,消费者无锁
    tail := q.tail
    if tail == atomic.LoadInt64(&q.head) {
        return entry{}, false
    }
    e := q.buf[tail&(int64(len(q.buf))-1)]
    atomic.StoreInt64(&q.tail, tail+1)
    return e, true
}

注意 Push 最后那行注释:锁管住了生产者之间的写权,但消费者不拿锁,它看不到锁的 happens-before——所以发布 head 依然必须走 atomic。锁保写权,atomic 保发布,两种机制各管一件事,缺一不可。

对比路线一:mutex 快路径就是一次 CAS,慢路径休眠让核,比自旋重试对调度器友好得多。Go 标准库的 channel 干脆用一把 hchan.lock 罩住整个通道,收发全锁——标准库都选了简单。绝大多数场景,生产者侧加锁就是性价比最优解。

4. 路线三:别修了,改结构

前两条路都在修"共享的 head"。第三条路问:为什么要有一个共享的 head

per-slot 序列号

Vyukov 的 MPMC 队列、Disruptor 多生产者模式、JCTools,全是同一族:把队列的状态从全局下放到每个槽位

type cell struct {
    seq  int64 // 槽位状态机:== pos 可写,== pos+cap 可读
    data entry
}

// 生产者:cell[pos&mask].seq == pos → CAS head 认领 → 写 data
//        → store cell.seq = pos + cap(release 发布本槽)
// 消费者:cell[pos&mask].seq == pos + cap → 读 data
//        → store cell.seq = pos + 1(槽位进入下一圈)

发布不再依赖全局边界:每个槽位自带 ready 标志,committed 那个排队交卷的瓶颈直接消失。热点也被打散——N 个槽位是 N 个独立状态机,两个生产者写相邻槽位,争的不再是同一个变量(做好缓存行填充后连同一个缓存行都不是)。

归并写者

更激进的方向:在业务层把 N 个生产者收敛成 1 个——一个前置 channel 或单 goroutine 收敛所有写入,环形队列退化回 SPSC,第一篇的无锁实现原样复用。看起来像抖机灵(在队列前面再垫一个队列),但 Actor 模型、单写者事件溯源,全是这个思路的严肃应用。

5. 选型与注意事项

路线 写路径成本 复杂度 适合
CAS + committed 无锁,但自旋重试 + 排队交卷 生产者少、禁止上锁的硬实时
生产者侧 mutex 快路径一次 CAS,竞争时休眠让核 默认选择
per-slot 序列号 槽位级并行,吞吐最高 最高 高性能库(Disruptor、JCTools)
归并写者 转嫁给前置通道 架构级 Actor / 单写者风格系统
  • ⚠️ ABA:这里用 int64 单调计数,实际跑 584 年才会回绕,CAS 没有 ABA 之忧。把下标截成 32 位存储时才需要担心
  • ⚠️ 自旋 vs 休眠:核多于线程时自旋是优势(不让核心),核超订阅时是灾难(烧 CPU 换空转)。Gosched() 是两者的妥协
  • ⚠️ false sharinghead/committed/tail 要各占独立缓存行;多生产者 CAS 同一个 head 本身就是缓存行争用热点,per-slot 方案把热点打散正是为了这个
  • ✅ 验证:路线一、二都能过 go test -race——atomic 访问被视为同步,不会误报

小结

上一篇给过无锁的公式:唯一写者 + release/acquire + 缓存行隔离。两个生产者打碎了第一项,三条修复路线对应三种哲学:乐观地重试、悲观地排队、重新设计让前提复活。

CAS 不是无锁的银弹——它只是把"等"换成了"重试",把锁的临界区压缩成一条指令。真正的高手不修被打破的前提,而是把结构改成前提从未被打破的样子。修 bug 的最高境界,是让 bug 的前提不成立。


最后更新:2026-09-07

评论