两个生产者之后:给无锁环形队列加同步的三条路
前作讲 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
}
正确性逐条对上:
- 每个成功的 CAS 拿到唯一槽位号,两个生产者写同一格的事故根除
- 消费者只看
committed,认领了但没交卷的槽位它读不到——空槽问题根除 - 消费者读完数据才推进
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 sharing:
head/committed/tail要各占独立缓存行;多生产者 CAS 同一个head本身就是缓存行争用热点,per-slot 方案把热点打散正是为了这个 - ✅ 验证:路线一、二都能过
go test -race——atomic 访问被视为同步,不会误报
小结
上一篇给过无锁的公式:唯一写者 + release/acquire + 缓存行隔离。两个生产者打碎了第一项,三条修复路线对应三种哲学:乐观地重试、悲观地排队、重新设计让前提复活。
CAS 不是无锁的银弹——它只是把"等"换成了"重试",把锁的临界区压缩成一条指令。真正的高手不修被打破的前提,而是把结构改成前提从未被打破的样子。修 bug 的最高境界,是让 bug 的前提不成立。
最后更新:2026-09-07