Go 手写 Wait-Free SPSC 无界队列:无 CAS、无锁、泛型节点池

源码: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 把 headtailnodePool 隔在不同 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
}

两步操作

  1. atomic.StorePointer(&q.head.next, ...) --- 对消费者可见,内含 Release 屏障,保证 n.val = v 先于指针写入
  2. 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

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

源码unboundedspsc.go

交流群:QQ 群 1098078562

公众号:Zhenyi-io

相关推荐
Lucien3231 小时前
学完 Spring Boot 再看 FastAPI,我破防了
后端
小小龙学IT1 小时前
Go 语言后端开发:从并发模型到生产落地的工程实践
开发语言·后端·golang
程序员cxuan1 小时前
Agents.md 是什么
人工智能·后端·程序员
Chen_harmony2 小时前
一、数据结构概念和复杂度计算
数据结构
摇滚侠2 小时前
Java 零基础全套教程,类的加载过程与类加载器的理解,笔记 189
java·后端·intellij-idea
ServBay2 小时前
为什么我劝你不要在Mac上用Docker 进行本地 AI 开发
后端
小欣加油2 小时前
leetcode287寻找重复数
数据结构·c++·算法·leetcode
蝎子莱莱爱打怪2 小时前
XZLL-IM干货系列 02|Protobuf 协议设计:从 JSON 切到二进制,每条消息省了 60%
后端·面试·架构
程序员黑豆2 小时前
AI全栈开发之Java:第一个Java程序
前端·后端·ai编程