源码:github.com/aiyang-zh/z...(MIT 协议)
标签:Go / Wait-Free / SPSC / 无界队列 / 泛型 / 对象池 / unsafe
前言
前面拆解了有界 MPSC 环形队列------序列号协议 + CAS 竞争,消费者零竞争,入队出队零分配。
但有一个前提:生产者只有一个 。如果彻底退化到 SPSC(Single Producer Single Consumer),可以走得更远------连 CAS 都不需要,只有 Load/Store。
这篇拆解 UnboundedSPSC:
- 无界链表,动态增长
- 生产者/消费者严格隔离在不同字段,互不干扰
- 入队出队全程只用
atomic.StorePointer/atomic.LoadPointer,达到 wait-free - 泛型节点 + zpool 对象池,避免频繁 GC 压力
- 消费者本地回收缓存,批量归还节点,避免每次出队都触发 sync.Pool
一、方案选型:为什么 SPSC 能做到 Wait-Free
Lock-Free vs Wait-Free 的区别:
| 级别 | 定义 | 典型操作 |
|---|---|---|
| Lock-Free | 全局有进展,单个线程可能被饿死 | CAS 重试(可能无限循环) |
| Wait-Free | 每个线程有界步骤内必然完成 | 只用 Store/Load,无重试 |
MPSC 需要多个生产者竞争 head,必须用 CAS 仲裁所有权。而 SPSC 只有一个生产者,head 完全私有------生产者写 head.next + head,不需要任何竞争协议。消费者只读 tail.next + tail,同样无竞争。
bash
生产者(唯一) 消费者(唯一)
| |
写 head.next 读 tail.next
(StorePointer) (LoadPointer)
更新 head 更新 tail
| |
私有字段 私有字段
两者的唯一交汇点是 atomic.StorePointer / LoadPointer------这是一个 Release/Acquire 屏障,保证生产者写入 next 之后消费者才能读到。
二、数据结构
节点定义
go
type spscNode[T any] struct {
next unsafe.Pointer // *spscNode[T]
val T // 直接存储 T,不用 any(避免装箱)
}
val T 直接存储,避免 interface{} 装箱/断言开销。
队列主体
go
type UnboundedSPSC[T any] struct {
// 生产者专用区
head *spscNode[T]
_ [cacheLineSize]byte // padding,防止 false sharing
// 消费者专用区
tail *spscNode[T]
_ [cacheLineSize]byte
// 节点对象池(生产者/消费者共享,sync.Pool 底层)
nodePool *zpool.Pool[*spscNode[T]]
_ [cacheLineSize]byte
// 消费者本地回收缓存(仅消费者线程访问)
recycleCache []*spscNode[T]
recycleCap int
}
三段 padding 把 head、tail、nodePool 隔在不同 cache line------生产者和消费者字段不在同一条 cache line,互相不会触发伪共享。
三、初始化:哨兵节点
go
func NewUnboundedSPSC[T any]() *UnboundedSPSC[T] {
q := &UnboundedSPSC[T]{
recycleCap: 256,
nodePool: zpool.NewPool(func() *spscNode[T] {
return &spscNode[T]{}
}),
}
q.recycleCache = make([]*spscNode[T], 0, q.recycleCap)
sentinel := &spscNode[T]{} // 哨兵节点
q.head = sentinel
q.tail = sentinel
return q
}
head == tail == sentinel,哨兵节点让判空逻辑更统一:消费者只需检查 tail.next == nil,不需要特判 head == tail 的边界情况。
四、入队(生产者,Wait-Free)
go
func (q *UnboundedSPSC[T]) Enqueue(v T) {
n := q.nodePool.Get()
n.val = v
n.next = nil
atomic.StorePointer(&q.head.next, unsafe.Pointer(n))
q.head = n
}
两步操作:
atomic.StorePointer(&q.head.next, ...)--- 对消费者可见,内含 Release 屏障,保证n.val = v先于指针写入q.head = n--- 生产者自己更新 head,无需原子(私有字段)
没有 CAS,没有重试,wait-free。
批量入队
go
func (q *UnboundedSPSC[T]) EnqueueBatch(elements []T) int {
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
atomic.StorePointer(&q.head.next, unsafe.Pointer(first)) // 一次 Release,链表全可见
q.head = last
return len(elements)
}
关键点:中间节点的 next 用非原子写,只有链表的入口(head.next)用 atomic.StorePointer 一次性发布。
这是合法的,因为消费者只能通过 tail.next 顺序遍历,链表内部节点在入口发布之前对消费者不可见。一次 Release 屏障保证了在此之前的所有写入(n.val、中间 next)对消费者可见。
五、出队(消费者,Wait-Free)
go
func (q *UnboundedSPSC[T]) Dequeue() (T, bool) {
tail := q.tail
next := (*spscNode[T])(atomic.LoadPointer(&tail.next))
if next != nil {
v := next.val
var zero T
next.val = zero // 清零,释放引用,帮助 GC
q.tail = next
// 归还旧 tail(前一个哨兵节点)到本地缓存
tail.next = nil
tail.val = zero
q.recycleLocal(tail)
return v, true
}
var zero T
return zero, false
}
atomic.LoadPointer(&tail.next) 含 Acquire 屏障,保证消费者读到 next 指针后,next.val 的值已经是生产者写入后的最新值。
出队后旧 tail(哨兵)不再需要,回收到本地缓存复用。
批量出队
go
func (q *UnboundedSPSC[T]) DequeueBatch(buffer []T) int {
count := 0
limit := len(buffer)
tail := q.tail
next := (*spscNode[T])(atomic.LoadPointer(&tail.next))
for next != nil && count < limit {
buffer[count] = next.val
count++
var zero T
next.val = zero
oldTail := tail
tail = next
oldTail.next = nil
oldTail.val = zero
q.recycleLocal(oldTail)
next = (*spscNode[T])(atomic.LoadPointer(&tail.next))
}
q.tail = tail
return count
}
批量出队中,每个 atomic.LoadPointer 都是一次 Acquire------这是必须的,不能优化掉,否则无法保证读到当前节点的 next 时,下一个节点的 val 已经写入。
六、节点回收:本地缓存 + 批量归还
每次出队都回收一个旧节点。若每次都直接 Put 回 sync.Pool,在高频出队时会造成大量 Pool 竞争。解法:本地回收缓存。
go
func (q *UnboundedSPSC[T]) recycleLocal(node *spscNode[T]) {
if len(q.recycleCache) < q.recycleCap {
q.recycleCache = append(q.recycleCache, node)
} else {
// 缓存满了,批量归还
for _, n := range q.recycleCache {
q.nodePool.Put(n)
}
q.recycleCache = q.recycleCache[:0]
q.recycleCache = append(q.recycleCache, node)
}
}
本地缓存容量 256(可配置)。缓存满后批量归还到 sync.Pool,等效于 256× 摊薄 Pool 竞争。
注 :
recycleCache仅消费者线程访问,不需要任何同步。
七、Close 语义
go
func (q *UnboundedSPSC[T]) Close() {
for _, node := range q.recycleCache {
if node != nil {
q.nodePool.Put(node)
}
}
q.recycleCache = nil
}
Close 只做一件事:把本地缓存里积累的节点批量归还 Pool,避免资源泄漏。
和 MPSCQueue.Close() 的区别:MPSC 的 Close 会设置 closed 标志阻止生产者入队,因为 MPSC 有多个生产者需要被通知。SPSC 只有一个生产者,关闭语义由调用方协调,队列本身不维护 closed 标志。
八、性能
全程只有 atomic.StorePointer / atomic.LoadPointer,没有 CAS,没有重试循环------每个入队/出队操作在固定数量的步骤内完成(wait-free),无重试、无竞争。
代价是链表布局带来 cache miss ,实际延迟高于有界环形数组(如 SPSC 环形队列的数组布局更紧凑)。UnboundedSPSC 的核心优势是:无界增长 + wait-free 有界步数保证,而非绝对延迟最低。
九、已知局限
| 局限 | 说明 |
|---|---|
| 严格 SPSC | 多生产者或多消费者均为 undefined behavior,不会崩溃但数据会损坏 |
| 无界增长 | 队列无上限,消费者跟不上时内存持续增长 |
| 链表布局 | 节点不连续,cache miss 率高于环形数组;对象池可缓解,但不能消除 |
| Close 无通知 | 关闭后生产者不会收到信号,需调用方自行协调停止入队 |
适用场景
- Goroutine 间一对一任务传递:一个生产者 goroutine 推送任务,一个消费者 goroutine 处理,队列深度不可预期
- 事件流水线:单线程生成事件,单线程消费(如日志收集、指标上报)
- 解耦缓冲:生产速率突发但平均可控,需要缓冲但不想限制上限
如果你需要多生产者,参考同系列的 MPSCQueue(有界环形)或 UnboundedMPSC(无界链表)。
⭐ 觉得有帮助的话点个 Star 吧,有问题欢迎提 Issue
源码 :unboundedspsc.go
交流群:QQ 群 1098078562
公众号:Zhenyi-io