sync/atomic 不能直接实现安全无锁队列,因其仅保证单操作原子性,无法确保多步逻辑(如读头→取值→更新指针)的原子性,易引发 aba 问题、内存泄漏及 gc 提前回收等风险。

为什么 sync/atomic 不能直接实现安全的无锁队列
因为原子操作本身不保证多步逻辑的原子性。比如“读头节点→取值→更新头指针”这三步,哪怕每步都用 atomic.LoadPointer 和 atomic.CompareAndSwapPointer,中间仍可能被其他 goroutine 插入或修改,导致 ABA 问题或内存泄漏。
真实场景中,你很快会遇到:指针被回收后又被复用、CAS 失败却不重试、节点内存未正确释放——这些都不是加个 atomic 就能绕开的。
- Go 的
unsafe.Pointer转换必须严格配对,漏掉一次atomic.StorePointer就可能让 GC 提前回收节点内存 -
atomic.CompareAndSwapPointer返回false时,必须重新加载头/尾指针再试,不能直接 panic 或忽略 - 标准库没有提供带内存屏障语义的 relax/acquire/release 模式,得靠
atomic.LoadAcquire/atomic.StoreRelease(Go 1.19+)补足,旧版本只能靠atomic.LoadPointer+ 注释硬扛
用 sync/atomic + unsafe.Pointer 手写单生产者单消费者(SPSC)队列的关键步骤
SPSC 是唯一能用纯原子操作稳妥落地的场景,它规避了竞争判断逻辑,把复杂度压到最低。
核心是两个指针:head(消费者读位置)、tail(生产者写位置),共用一个环形数组,用 atomic.LoadUint64 读、atomic.AddUint64 推进,再通过位掩码做索引映射。
立即学习“go语言免费学习笔记(深入)”;
- 数组长度必须是 2 的幂,否则位掩码
& (cap - 1)会越界 - 判空和判满不能只比对
head == tail,要额外维护一个size字段或用head和tail的差值做无符号比较(避免负数截断) - 写入新元素前,必须先用
atomic.LoadAcquire看tail,写完再用atomic.StoreRelease更新;读同理,否则 CPU 乱序执行会导致看到未初始化的数据
type SPSCQueue struct {
buf []int
head uint64
tail uint64
mask uint64
}为什么别急着上 MPSC/MPMC:Go runtime 对 CAS 的调度成本比你想象中高
Go 的 goroutine 调度器不是为细粒度无锁竞争优化的。当多个 goroutine 频繁在同一个地址上 atomic.CompareAndSwapPointer,会触发大量自旋、G-P 绑定抖动,甚至让 P 长时间无法调度其他 G。
实测表明,在 8 核机器上,MPMC 场景下纯原子队列吞吐量常低于带 sync.Mutex 的简单 channel(尤其是元素小、操作快时)。
- channel 在底层已针对小消息做了优化(如栈上缓冲、非阻塞快速路径),不是“低效”的代名词
-
runtime/cgo调用或CGO_ENABLED=0构建会影响atomic指令生成,某些 ARM64 环境下atomic.LoadAcquire可能被降级为普通 load - 真要高性能多生产者,优先考虑分片(sharding):每个生产者写独立子队列,消费者合并读,比硬刚一个 MPMC 原子队列更稳
调试原子队列时最常卡住的三个地方
现象往往不报错,而是偶发 panic、数据丢失、goroutine 卡死——因为问题出在内存模型和时序上,不是语法错误。
-
unsafe.Pointer转*node后,没用runtime.KeepAlive告诉 GC 这个指针还在用,导致节点内存被提前回收,后续解引用就 panic - 用
atomic.LoadUint64读head后,直接算索引并读buf[idx],但没加内存屏障,编译器或 CPU 可能把这两次读重排序,读到旧值 - 测试时用
for i := 0; i 启 1000 个 goroutine 压测,结果调度器根本没真正并发,全串行跑完,误以为“没问题”
真正难的从来不是写对那几行 atomic 调用,而是想清楚哪一步需要 acquire,哪一步需要 release,以及 GC 会在哪个瞬间把你的节点收走。











