源码:github.com/aiyang-zh/z...(MIT 协议)
标签:Go / Wait-Free / SPSC / Ring Buffer / 无锁 / 泛型 / 自旋退避
前言
上一篇拆解了 UnboundedSPSC------链表 + StorePointer/LoadPointer,wait-free,但链表布局带来指针追逐和 cache miss。
如果场景允许有界队列(容量可预期、不希望无限增长),环形数组是更好的选择:
- 内存连续,CPU prefetch 友好,cache hit 率远高于链表
- 零分配,buffer 预分配,运行期无堆对象
- Try 系列是 wait-free*:入队出队在固定步数内完成,无自旋、无重试
- 阻塞版用有界自旋替代 runtime 调度,适合高频场景,不会触发 goroutine 阻塞
这篇拆解 SPSCQueue:
- 有界环形数组,容量向上取整为 2 的幂,用位与替代取模
- 生产者/消费者字段严格分离 + cache line padding,彻底消除伪共享
- 入队出队只用
atomic.Uint64.Load/Store,无 CAS TryEnqueue/TryDequeue无自旋、无重试,固定步数完成(wait-free)- 阻塞版在队列满/空时有界自旋 + 退避,不会触发 goroutine 调度
- 自旋 + 退避策略实现阻塞等待,适合高频场景
- 同时提供阻塞 / Try 两套 API,满足不同延迟需求
一、方案选型:环形数组 vs 链表
同样是 SPSC,什么时候用哪个?
| 维度 | SPSCQueue(环形数组) |
UnboundedSPSC(链表) |
|---|---|---|
| 内存布局 | 连续数组,cache 友好 | 分散节点,指针追逐 |
| 分配行为 | 预分配,零运行时 alloc | 每个入队分配节点(池化缓解) |
| 容量 | 有界,满时阻塞或丢弃 | 无界,内存可无限增长 |
| 适用场景 | 速率可预期、低延迟要求 | 速率不可预期、允许缓冲增长 |
核心结论:能用有界就不要用无界,环形数组的 cache 局部性是链表无法比拟的。
二、数据结构
队列主体
go
type SPSCQueue[T any] struct {
// --- 生产者独占字段 ---
head atomic.Uint64
_padding0 [cacheLineSize]byte
tailCache uint64
_padding1 [cacheLineSize]byte
// --- 消费者独占字段 ---
tail atomic.Uint64
_padding2 [cacheLineSize]byte
headCache uint64
_padding3 [cacheLineSize]byte
// --- 共享状态字段 ---
closed atomic.Bool
_padding4 [cacheLineSize]byte
// --- 只读常量字段 ---
mask uint64
buffer []T
}
五个 cache line padding,把生产者的 head、消费者的 tail、共享的 closed 全部隔开------SPSC 场景下生产者和消费者各自只有一个线程,它们访问的字段互不干扰,padding 保证它们不在同一条 cache line 上竞争。
head 和 tail 用 atomic.Uint64,比 unsafe.Pointer 更安全,对齐也更好。
tailCache / headCache 优化
这是环形队列的经典优化:本地缓存对端的游标,避免每次都做原子读。
- 生产者维护
tailCache:上次读到的tail值,判满时先比缓存,缓存不够才做原子读 - 消费者维护
headCache:上次读到的head值,判空时先比缓存,缓存不够才做原子读
效果:高频入队时,绝大多数判满操作只比本地 tailCache,零原子操作。
三、初始化:容量向上取整为 2 的幂
go
func NewSPSCQueue[T any](capacity int) *SPSCQueue[T] {
if capacity < 2 {
capacity = 2
}
if capacity > maxCapacity {
capacity = maxCapacity
}
if !isPowerOfTwo(capacity) {
capacity = nextPowerOfTwo(capacity)
}
return &SPSCQueue[T]{
buffer: make([]T, capacity),
mask: uint64(capacity - 1),
}
}
mask = capacity - 1,后续用 pos & mask 替代 % capacity,位运算比取模快一个数量级。
head 和 tail 是单调递增 的 uint64,不会回绕。判空用 head == tail,判满用 head - tail >= capacity,两者永远不会混淆。
四、入队(生产者)
单个入队(阻塞)
go
func (q *SPSCQueue[T]) Enqueue(item T) bool {
if q.closed.Load() {
return false
}
head := q.head.Load()
capacity := q.mask + 1
tailLimit := q.tailCache + capacity
if head >= tailLimit {
// 缓存不够,原子读真实 tail
realTail := q.tail.Load()
if head >= realTail+capacity {
// 空间不足,自旋等待
spinCount := 0
for {
if q.closed.Load() {
return false
}
realTail = q.tail.Load()
if head < realTail+capacity {
break
}
zbackoff.Backoff(spinCount, 10, 30, 10*time.Microsecond)
spinCount++
}
}
q.tailCache = realTail
}
q.buffer[head&q.mask] = item
q.head.Store(head + 1)
return true
}
关键点:
head是原子变量,但只有生产者自己写 ,所以Load()后到Store()之间head不会变------这里的head读一次就够了tailCache是普通变量,只有生产者线程读/写,不需要原子- 空间不足时自旋 + 退避 :
zbackoff.Backoff会在指数退避和上限之间平衡,避免 CPU 空转浪费 q.head.Store(head + 1)内含 Release 屏障,保证buffer[head&mask] = item先于head发布
批量入队
go
func (q *SPSCQueue[T]) EnqueueBatch(items []T) bool {
// ... 判满逻辑同上 ...
offset := head & q.mask
toEnd := capacity - offset
if count <= toEnd {
copy(q.buffer[offset:offset+count], items)
} else {
// 回绕:分成两段拷贝
copy(q.buffer[offset:], items[:toEnd])
copy(q.buffer[0:], items[toEnd:])
}
q.head.Store(targetHead)
return true
}
用 copy 而不是循环赋值------Go 内置 copy 对连续内存有编译器内联优化,比逐元素赋值快得多。
回绕处理:当 head 接近数组末尾时,批量数据可能跨越数组边界,需要分成两段 copy。
五、出队(消费者)
单个出队(阻塞)
go
func (q *SPSCQueue[T]) Dequeue() (T, bool) {
tail := q.tail.Load()
var zero T
if tail >= q.headCache {
realHead := q.head.Load()
if tail >= realHead {
// 队列为空,自旋等待
spinCount := 0
for {
realHead = q.head.Load()
if tail < realHead {
break
}
if q.closed.Load() {
if q.head.Load() <= tail {
return zero, false
}
}
zbackoff.Backoff(spinCount, 10, 30, 10*time.Microsecond)
spinCount++
}
}
q.headCache = realHead
}
index := tail & q.mask
val := q.buffer[index]
q.buffer[index] = zero // 清零,释放引用,帮助 GC
q.tail.Store(tail + 1)
return val, true
}
关键点:
- 出队后把
buffer[index]清零------这很重要,如果T是指针或包含指针的类型,不清零会导致底层对象无法被 GC 回收 q.tail.Store(tail + 1)内含 Release 屏障,保证清零操作先于tail发布(虽然这里消费者是唯一读者,但屏障保证了内存序的正确性)closed检查在自旋循环里:队列空且关闭时,返回(零值, false)让调用方知道结束
批量出队 + GC 清理
go
func (q *SPSCQueue[T]) consumeBatch(tail, batchSize uint64, limit []T) int {
offset := tail & q.mask
capacity := q.mask + 1
toEnd := capacity - offset
var zero T
if batchSize <= toEnd {
copy(limit[:batchSize], q.buffer[offset:offset+batchSize])
// 清零已读 slot
for i := uint64(0); i < batchSize; i++ {
q.buffer[offset+i] = zero
}
} else {
// 回绕:两段拷贝 + 两段清零
copy(limit[:toEnd], q.buffer[offset:])
remaining := batchSize - toEnd
copy(limit[toEnd:batchSize], q.buffer[:remaining])
for i := uint64(0); i < toEnd; i++ {
q.buffer[offset+i] = zero
}
for i := uint64(0); i < remaining; i++ {
q.buffer[i] = zero
}
}
q.tail.Store(tail + batchSize)
return int(batchSize)
}
回绕场景的清零不能漏------buffer 是复用的,旧数据不清理会导致 GC 泄漏。
六、非阻塞 API:TryEnqueue / TryDequeue
阻塞版适合"一定要写入/读出"的场景,但有些场景不能等 (实时系统、尾部丢弃策略),所以提供了 Try* 系列:
go
func (q *SPSCQueue[T]) TryEnqueue(item T) bool {
// ... 判满 ...
if head >= realTail+capacity {
return false // 空间不足,立即返回
}
// ... 写入 ...
}
go
func (q *SPSCQueue[T]) TryDequeueBatch(limit []T) (int, bool) {
// ... 判空 ...
if tail >= realHead {
if q.closed.Load() {
return 0, false // 已关闭且空
}
return 0, true // 暂时空,未关闭
}
// ... 读取 ...
}
TryDequeueBatch 的返回值语义:
(n>0, true):成功读取 n 条(0, true):队列空但未关闭,可以重试(0, false):队列已关闭且空,不应再读
这个语义和 channel 的 ok 模式一致,调用方容易理解。
七、Close 语义
go
func (q *SPSCQueue[T]) Close() {
q.closed.Store(true)
}
Close 只设置一个标志,不通知生产者(SPSC 只有一个生产者,由调用方协调)。
和 UnboundedSPSC.Close() 的区别:UnboundedSPSC 需要把本地回收缓存归还 Pool,而 SPSCQueue 的 buffer 是预分配的,不需要额外清理。
关闭后:
- 写入 :
Enqueue/EnqueueBatch立即返回false - 读取 :已有的数据仍可读取,读完
head == tail后Dequeue返回(零值, false)
八、性能特点
TryEnqueue/TryDequeue 全程只有 atomic.Uint64.Load/Store,无 CAS、无自旋、无重试------固定步数内完成,真正 wait-free。
阻塞版 Enqueue/Dequeue 在队列满/空时会自旋等待。但实践中自旋有界(对端迟早会推进),且退避策略避免了 CPU 空转,性能仍远优于 channel(无 runtime 调度开销)。
环形数组的 cache 局部性远优于链表 :buffer 连续存储,批量出队时 CPU 硬件预取几乎全中,DequeueBatch 的连续读取接近直接读 slice。
tailCache/headCache 进一步优化了判满/判空的原子读频率,高频场景下绝大多数操作只访问本地 cache,零跨核通信。
九、已知局限
| 局限 | 说明 |
|---|---|
| 严格 SPSC | 多生产者或多消费者均为 undefined behavior |
| 有界容量 | 消费者跟不上时,生产者会阻塞(或丢弃,取决于用阻塞版还是 Try 版) |
| 自旋等待 | 阻塞版在队列满/空时自旋,极端情况下可能浪费 CPU(但退避策略缓解了这个问题) |
| 容量 2 的幂 | 容量向上取整,可能比预期大 |
适用场景
- 高频 goroutine 间通信:比 channel 更低的调度开销(无 runtime 介入),适合超高频 events/sec 场景
- 音频/视频帧缓冲:固定大小的帧队列,生产者逐帧写入,消费者逐帧读出
- Disruptor 模式轻量版:单生产者单消费者的环形缓冲,是 Disruptor 模式的核心数据结构
- 替换 channel:当你需要批量 API、或更细粒度的阻塞控制时
如果你需要无界 队列,参考同系列的 UnboundedSPSC(链表版)。如果你需要多生产者 ,参考 MPSCQueue(有界环形)或 UnboundedMPSC(无界链表)。
⭐ 觉得有帮助的话点个 Star 吧,有问题欢迎提 Issue
源码 :spsc.go
交流群:QQ 群 1098078562
公众号:Zhenyi-io