源码:github.com/aiyang-zh/z...(MIT 协议)
标签:Go / Wait-Free / MPSC / 无界队列 / 泛型 / 对象池 / unsafe
前言
前面拆解了 UnboundedSPSC(单生产者单消费者),生产者独享 head,只用 StorePointer/LoadPointer 就能实现 wait-free。
但生产环境更常见的是 多生产者、单消费者 的场景:
- Actor 模型的 Mailbox:多个 goroutine 给同一个 actor 发消息
- 日志收集队列:多个业务线程写日志,单个刷盘线程消费
- 事件分发器:多个源头产生事件,单个处理循环分发
MPSC 的核心难点是:多个生产者同时竞争入队位置,怎么做到无锁、且尽量快?
UnboundedMPSC 的解法是 atomic.SwapPointer:
- 入队只用
SwapPointer一个原子操作 ,无 CAS 重试循环 → wait-free - 哨兵节点 + 链表结构,无界动态增长
- 泛型节点
mpscNode[T]直接存储值,避免interface{}装箱 - 双端对象池
zpool.Pool+ 消费者本地回收缓存,摊薄 GC 压力 StopEnqueue+ 二次检查,收紧"停止入队"与链式入队之间的 TOCTOU
一、方案选型:为什么 SwapPointer 能做到 Wait-Free
MPSC 场景下,多个生产者要同时把节点挂到链表上。常见的两种方案:
| 方案 | 原子操作 | 是否 Wait-Free | 问题 |
|---|---|---|---|
CAS 自旋(MPSCQueue 有界版) |
CompareAndSwap 循环重试 |
❌ Lock-Free | 高并发下 CAS 竞争严重 |
SwapPointer(本实现) |
单次 Swap 无条件成功 |
✅ Wait-Free | 需要哨兵节点配合 |
SwapPointer 的语义是:原子地替换指针,并返回旧值。
go
// 伪代码
prev := atomic.SwapPointer(&q.head, unsafe.Pointer(newNode))
// prev 是原来的 head(链表尾节点)
// 现在 head 已经指向 newNode,但链表还没连通
atomic.StorePointer(&prev.next, unsafe.Pointer(newNode))
// 连通链表:prev.next = newNode
整个过程没有循环、没有重试,每个生产者线程在固定 2 次原子操作内完成入队,符合 wait-free 定义。
消费者端是单线程,不需要任何原子竞争协议,tail 的更新直接赋值即可。
二、数据结构
节点定义
go
type mpscNode[T any] struct {
next unsafe.Pointer // *mpscNode[T]
val T // 直接存储 T,不用 any(避免装箱)
}
val T 直接存储,避免 interface{} 装箱/断言开销。
队列结构
go
type UnboundedMPSC[T any] struct {
// head 指向最新插入的节点(生产者原子交换)
head unsafe.Pointer // *mpscNode[T]
_ [cacheLineSize]byte
// tail 指向当前哨兵节点(消费者读取)
tail unsafe.Pointer // *mpscNode[T]
_ [cacheLineSize]byte
// 生产者端对象池(多线程安全,用于 Enqueue)
nodePool *zpool.Pool[*mpscNode[T]]
_ [cacheLineSize]byte
// 消费者端本地缓存(单线程,用于 Dequeue 归还)
recycleCache []*mpscNode[T]
recycleCap int
// enqueueStopped:单消费者调用 StopEnqueue 后,
// TryEnqueue 在链入链表前二次检查并不再接受新元素
enqueueStopped atomic.Bool
}
Cache Line Padding 的必要性 :head 是生产者竞争热点,tail 是消费者私有字段,两者放在不同 cache line 避免 false sharing。
三、初始化:哨兵节点
go
func NewUnboundedMPSC[T any]() *UnboundedMPSC[T] {
q := &UnboundedMPSC[T]{
recycleCap: 256,
nodePool: zpool.NewPool(func() *mpscNode[T] {
return &mpscNode[T]{}
}),
}
q.recycleCache = make([]*mpscNode[T], 0, q.recycleCap)
sentinel := &mpscNode[T]{}
sentinel.next = nil
q.head = unsafe.Pointer(sentinel)
q.tail = unsafe.Pointer(sentinel)
return q
}
head == tail == sentinel,哨兵节点是"队列为空"的标志。消费者检查 tail.next == nil 来判断空队列,不需要特判 head == tail 的边界情况。
recycleCap 设为 256,消费者本地缓存最多持有 256 个节点,避免长期运行后内存驻留过高。
四、入队(单个):SwapPointer 实现 Wait-Free
go
func (q *UnboundedMPSC[T]) TryEnqueue(v T) bool {
if q.enqueueStopped.Load() {
return false
}
n := q.nodePool.Get()
n.val = v
atomic.StorePointer(&n.next, nil)
// 二次检查:如果已停止,回滚归还节点
if q.enqueueStopped.Load() {
var zero T
n.val = zero
q.nodePool.Put(n)
return false
}
prev := (*mpscNode[T])(atomic.SwapPointer(&q.head, unsafe.Pointer(n)))
atomic.StorePointer(&prev.next, unsafe.Pointer(n))
return true
}
执行步骤拆解
bash
初始状态:... → prev(旧 head)
↑
head
Step 1: SwapPointer(&q.head, n)
→ prev = 旧 head(原子获取)
→ head 指向 n(原子更新,其他生产者同时 Swap 会拿到不同 prev)
↑
head → n(未连通)
Step 2: StorePointer(&prev.next, n)
→ ... → prev → n(链表连通)
↑
head → n
关键点 :SwapPointer 保证每个生产者拿到独立的 prev,不会出现两个生产者同时往同一个 prev.next 写的情况。
StopEnqueue 的 TOCTOU 防护
StopEnqueue 和 TryEnqueue 之间存在 TOCTOU(Time Of Check To Time Of Use)窗口:
ini
线程 A(生产者) 线程 B(消费者)
───────────────── ────────────────────
读取 enqueueStopped = false
调用 StopEnqueue()
设置 enqueueStopped = true
分配节点 n
链入链表 ← 此时队列已关闭,
但元素还是被加进去了
二次检查 解决了这个问题:分配节点后、链入链表前,再读一次 enqueueStopped,如果已停止则回滚(清零 val + 归还节点),保证"关闭后不再接受新元素"的语义严格成立。
Enqueue(非 try 版本)直接调用 TryEnqueue,失败时静默丢弃 元素。需要感知拒绝事件的场景,应使用 TryEnqueue 并根据返回值处理。
五、入队(批量):本地构建链表,一次性发布
go
func (q *UnboundedMPSC[T]) EnqueueBatch(elements []T) {
if len(elements) == 0 || q.enqueueStopped.Load() {
return
}
first := q.nodePool.Get()
first.val = elements[0]
current := first
for i := 1; i < len(elements); i++ {
n := q.nodePool.Get()
n.val = elements[i]
current.next = unsafe.Pointer(n) // 本地写,非原子
current = n
}
last := current
last.next = nil
// 二次检查
if q.enqueueStopped.Load() {
// 回滚:释放所有节点
var zero T
node := first
for node != nil {
next := (*mpscNode[T])(node.next)
node.val = zero
q.nodePool.Put(node)
node = next
}
return
}
prevHead := (*mpscNode[T])(atomic.SwapPointer(&q.head, unsafe.Pointer(last)))
atomic.StorePointer(&prevHead.next, unsafe.Pointer(first))
}
为什么中间节点用非原子写?
current.next = unsafe.Pointer(n) 这行没有用 atomic.StorePointer ,因为此时这条链表还在生产者的本地构建阶段,还没有被发布到共享链表上。
只有最后一步:
go
atomic.SwapPointer(&q.head, unsafe.Pointer(last)) // 发布整条链
atomic.StorePointer(&prevHead.next, unsafe.Pointer(first)) // 连通
SwapPointer 是一个 Release 屏障 ,保证本地构建的所有节点(包括 next 指针)在 head 被更新之前已经对所有线程可见。消费者通过 LoadPointer(Acquire)读取 head 时,能正确看到整条链表。
六、出队:单消费者无竞争
go
func (q *UnboundedMPSC[T]) Dequeue() (T, bool) {
tail := (*mpscNode[T])(atomic.LoadPointer(&q.tail))
next := (*mpscNode[T])(atomic.LoadPointer(&tail.next))
if next == nil {
var zero T
return zero, false
}
v := next.val
var zero T
next.val = zero // 清零,帮助 GC
atomic.StorePointer(&q.tail, unsafe.Pointer(next))
// 归还哨兵节点到本地缓存
tail.next = nil
tail.val = zero
if len(q.recycleCache) < q.recycleCap {
q.recycleCache = append(q.recycleCache, tail)
} else {
// 缓存满了,批量归还到对象池
for _, node := range q.recycleCache {
q.nodePool.Put(node)
}
q.recycleCache = q.recycleCache[:0]
q.recycleCache = append(q.recycleCache, tail)
}
return v, true
}
为什么 tail 的更新用 atomic.StorePointer?
消费者是单线程,tail 的更新不需要原子操作来保证正确性。但 tail 是指针,Go 的内存模型要求对指针的写入/读取使用原子操作,否则可能读到脏值(尤其在 32 位系统上指针写入不是原子的)。
用 atomic.StorePointer / atomic.LoadPointer 是最安全的做法,且性能开销极小(x86 上就是普通 store/load,只有编译器和 CPU 的 reorder 屏障)。
节点回收:本地缓存摊薄 Pool 竞争
recycleCache 是消费者私有的,不需要任何同步:
- 出队时把旧的哨兵节点(
tail移动前的位置)放进recycleCache - 缓存达到 256 时,批量归还到
zpool.Pool zpool.Pool底层是sync.Pool,批量归还减少了Put的调用频率
七、StopEnqueue / Close / Shrink
StopEnqueue
go
func (q *UnboundedMPSC[T]) StopEnqueue() {
q.enqueueStopped.Store(true)
}
由单消费者 调用,通知生产者"不再接受新元素"。与 TryEnqueue 内的二次检查配合,收紧 TOCTOU。
典型使用模式(Actor 模型):
go
// 消费者 goroutine
queue.StopEnqueue() // 先停止入队
close(exitChan) // 通知生产者 goroutine 退出
// 继续 Dequeue 直到 Empty(),确保队列中已有元素被处理完
for !queue.Empty() {
if v, ok := queue.Dequeue(); ok {
handle(v)
}
}
queue.Close()
Close
go
func (q *UnboundedMPSC[T]) Close() {
q.StopEnqueue()
for _, node := range q.recycleCache {
if node != nil {
q.nodePool.Put(node)
}
}
q.recycleCache = nil
}
Close 调用 StopEnqueue + 清空本地缓存。调用后队列不再使用,适合进程退出或 Actor 停止时调用。
Shrink
go
func (q *UnboundedMPSC[T]) Shrink() {
for _, node := range q.recycleCache {
if node != nil {
q.nodePool.Put(node)
}
}
q.recycleCache = q.recycleCache[:0]
}
清空消费者本地缓存,让 GC 回收 zpool.Pool 中多余的对象。适合在空闲时定期调用,降低长期运行后的内存驻留。
八、性能特点
入队(多生产者) :SwapPointer 是 wait-free 的,每个生产者固定 2 次原子操作(SwapPointer + StorePointer),无 CAS 重试,高并发下比 MPSCQueue(有界 CAS 版)竞争更少。
出队(单消费者) :无竞争,tail 更新用 StorePointer(x86 上几乎零开销),recycleCache 摊薄了 sync.Pool 的 Put 调用。
内存布局:链表节点不连续,有指针追逐的 cache miss 代价。这是无界链表的固有代价,换来了"无界、动态增长"的灵活性。
与 UnboundedSPSC 对比:
| UnboundedSPSC | UnboundedMPSC(本实现) | |
|---|---|---|
| 生产者数量 | 1 | 多个 |
| 入队原子操作 | StorePointer(更轻量) |
SwapPointer(wait-free,稍重) |
| 是否 Wait-Free | ✅ | ✅ |
| 内存布局 | 链表(cache miss) | 链表(cache miss) |
完整 benchmark 见 docs/benchmark。
九、已知局限
-
严格单消费者 :
Dequeue/DequeueBatch/Empty/Shrink/Close必须由同一个 goroutine 调用,多线程调用会导致tail竞争(未做保护,性能优先) -
无界增长 :没有容量上限,突发流量下内存可能持续增长。需要背压控制的场景,应使用有界
MPSCQueue -
链表 cache miss:节点不连续,遍历时 CPU cache 命中率低。对延迟极度敏感的场景,考虑有界环形数组版本
-
Enqueue静默丢弃 :调用Enqueue(非 try 版本)时,如果已StopEnqueue,元素会被静默丢弃。需要感知拒绝事件的场景,应使用TryEnqueue
十、适用场景
✅ 适合:
- Actor mailbox(多个 goroutine 发消息,单个 actor 处理)
- 日志收集(多个业务线程写,单个刷盘线程消费)
- 事件分发(多个源头产生事件,单个处理循环)
- 需要 wait-free 入队保证的低延迟场景
❌ 不适合:
- 需要多消费者(用
Queue或PriorityQueue) - 需要背压控制(用有界
MPSCQueue) - 对内存占用敏感(用有界环形数组版本)
⭐ 觉得有帮助的话点个 Star 吧,有问题欢迎提 Issue
源码 :unboundedmpsc.go
交流群:QQ 群 1098078562
公众号:Zhenyi-io