Go 手写 Wait-Free MPSC 无界队列:SwapPointer 实现多生产者无锁入队

源码: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 防护

StopEnqueueTryEnqueue 之间存在 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 是消费者私有的,不需要任何同步:

  1. 出队时把旧的哨兵节点(tail 移动前的位置)放进 recycleCache
  2. 缓存达到 256 时,批量归还到 zpool.Pool
  3. 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.PoolPut 调用。

内存布局:链表节点不连续,有指针追逐的 cache miss 代价。这是无界链表的固有代价,换来了"无界、动态增长"的灵活性。

UnboundedSPSC 对比

UnboundedSPSC UnboundedMPSC(本实现)
生产者数量 1 多个
入队原子操作 StorePointer(更轻量) SwapPointer(wait-free,稍重)
是否 Wait-Free
内存布局 链表(cache miss) 链表(cache miss)

完整 benchmark 见 docs/benchmark


九、已知局限

  1. 严格单消费者Dequeue / DequeueBatch / Empty / Shrink / Close 必须由同一个 goroutine 调用,多线程调用会导致 tail 竞争(未做保护,性能优先)

  2. 无界增长 :没有容量上限,突发流量下内存可能持续增长。需要背压控制的场景,应使用有界 MPSCQueue

  3. 链表 cache miss:节点不连续,遍历时 CPU cache 命中率低。对延迟极度敏感的场景,考虑有界环形数组版本

  4. Enqueue 静默丢弃 :调用 Enqueue(非 try 版本)时,如果已 StopEnqueue,元素会被静默丢弃。需要感知拒绝事件的场景,应使用 TryEnqueue


十、适用场景

适合

  • Actor mailbox(多个 goroutine 发消息,单个 actor 处理)
  • 日志收集(多个业务线程写,单个刷盘线程消费)
  • 事件分发(多个源头产生事件,单个处理循环)
  • 需要 wait-free 入队保证的低延迟场景

不适合

  • 需要多消费者(用 QueuePriorityQueue
  • 需要背压控制(用有界 MPSCQueue
  • 对内存占用敏感(用有界环形数组版本)

⭐ 觉得有帮助的话点个 Star 吧,有问题欢迎提 Issue

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

源码unboundedmpsc.go

交流群:QQ 群 1098078562

公众号:Zhenyi-io

相关推荐
张不才1 小时前
CPU 100% 了怎么办?Java 性能排障的标准化操作
java·后端
鱼人1 小时前
Redis、网关负载均衡为什么不能用普通取模哈希?
后端
juejin9982 小时前
Claude Code Lab-3(下):三能力 MCP Server
后端
java小白小2 小时前
SpringBoot(07):事务管理——@Transactional 你真的用对了吗?
后端
shepherd1113 小时前
吞吐量提升 10 倍:高并发大批量数据处理任务的架构演进与性能调优
java·后端·架构
java小白小3 小时前
SpringBoot(05):Spring Data JPA——用面向对象的方式操作数据库
后端
juejin9983 小时前
Claude Code Lab-2(上):自然语言查库助手
后端
java小白小3 小时前
SpringBoot(06):多数据源配置——一个项目连多个库怎么做
后端
程序员cxuan4 小时前
Codex 会把磁盘给烧了?完整复盘来了!
人工智能·后端·程序员