Go 手写有界 SPSC 环形队列:无 CAS、无锁、Cache 友好的无锁模型

源码: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 上竞争。

headtailatomic.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,位运算比取模快一个数量级。

headtail单调递增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
}

关键点

  1. head 是原子变量,但只有生产者自己写 ,所以 Load() 后到 Store() 之间 head 不会变------这里的 head 读一次就够了
  2. tailCache 是普通变量,只有生产者线程读/写,不需要原子
  3. 空间不足时自旋 + 退避zbackoff.Backoff 会在指数退避和上限之间平衡,避免 CPU 空转浪费
  4. 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
}

关键点

  1. 出队后把 buffer[index] 清零------这很重要,如果 T 是指针或包含指针的类型,不清零会导致底层对象无法被 GC 回收
  2. q.tail.Store(tail + 1) 内含 Release 屏障,保证清零操作先于 tail 发布(虽然这里消费者是唯一读者,但屏障保证了内存序的正确性)
  3. 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):队列已关闭且空,不应再读

这个语义和 channelok 模式一致,调用方容易理解。


七、Close 语义

go 复制代码
func (q *SPSCQueue[T]) Close() {
    q.closed.Store(true)
}

Close 只设置一个标志,不通知生产者(SPSC 只有一个生产者,由调用方协调)。

UnboundedSPSC.Close() 的区别:UnboundedSPSC 需要把本地回收缓存归还 Pool,而 SPSCQueue 的 buffer 是预分配的,不需要额外清理。

关闭后:

  • 写入Enqueue/EnqueueBatch 立即返回 false
  • 读取 :已有的数据仍可读取,读完 head == tailDequeue 返回 (零值, 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

仓库github.com/aiyang-zh/z...

源码spsc.go

交流群:QQ 群 1098078562

公众号:Zhenyi-io

相关推荐
咕白m6251 小时前
使用 C# 在 Excel 中应用多种字体样式
后端·c#
Java编程爱好者1 小时前
放弃 Spring AI?这 3 个开源框架,才是让 SpringBoot 玩转 AI Agent 的正解
后端
二月龙1 小时前
伪类与伪元素深度解析:before/after 实用案例
后端
码事漫谈2 小时前
时序数据库2026盘点:国产数据库如何以“融合多模”走出差异化之路?
前端·后端
浮游本尊2 小时前
Java学习第42天 - Spring 事务传播、隔离级别、锁机制与并发一致性
后端
道友可好2 小时前
让 AI 自己验收,等于让学生自己批卷
前端·人工智能·后端
鱼人2 小时前
响应式三巨头:rem / vw / em 深度对比,移动端到底该选谁?
后端
小强19882 小时前
Grid 网格布局实战:快速实现复杂网页排版
后端
胡志辉2 小时前
深入浅出 call、apply、bind
前端·javascript·后端