不能直接用 sync.Mutex 实现无锁队列,因其依赖操作系统互斥锁,引发线程挂起、上下文切换和锁竞争;真正的 Lock-Free 需基于原子CAS与内存序控制,避免ABA问题和GC提前回收,SPSC可用纯原子读写索引实现,而MPMC必须结合版本号与unsafe.Pointer处理ABA。

为什么不能直接用 sync.Mutex 实现“无锁”队列
“无锁”不是指不用同步原语,而是不依赖操作系统级互斥锁(如 sync.Mutex),避免线程挂起、上下文切换和锁竞争带来的延迟毛刺。用 sync.Mutex 包裹 push/pop 看似简单,但高并发下会成为瓶颈,尤其在 NUMA 架构或大量短生命周期 goroutine 场景中,Lock() 调用本身就有可观开销。
真正 Lock-Free 的核心是:任意线程在执行过程中失败或被调度走,都不影响其他线程继续完成操作——这必须靠原子读-改-写(CAS)配合内存序控制来保证。
- 常见错误:只对头/尾指针做
atomic.LoadUint64和atomic.StoreUint64,没用atomic.CompareAndSwapPointer做状态跃迁 → 无法防止 ABA 问题或中间态撕裂 - 必须用指针级 CAS(
atomic.CompareAndSwapPointer)操作节点地址,不能用整数 CAS 模拟指针逻辑 - Go 的
unsafe.Pointer转换需严格配对,漏掉runtime.KeepAlive可能导致节点被提前 GC
怎么用 atomic.CompareAndSwapPointer 实现单生产者单消费者(SPSC)环形队列
SPSC 是唯一能用纯 CAS + 数组索引实现真正无锁且无 ABA 风险的场景。关键在于:生产者只动 tail,消费者只动 head,二者不争同一变量,只需用原子操作读写两个索引,并靠模运算映射到固定大小数组。
示例节选(省略内存屏障细节):
立即学习“go语言免费学习笔记(深入)”;
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
type SPSCQueue struct {
buf []interface{}
head uint64
tail uint64
}
func (q *SPSCQueue) Push(val interface{}) bool {
tail := atomic.LoadUint64(&q.tail)
head := atomic.LoadUint64(&q.head)
size := uint64(len(q.buf))
if (tail+1)%size == head { // 已满
return false
}
q.buf[tail%size] = val
atomic.StoreUint64(&q.tail, tail+1) // 不需要 CAS,因为只有生产者写 tail
return true
}
- 注意:这里
tail和head用uint64是为规避 32 位平台上的 ABA(计数器溢出后回绕),实际生产环境建议用atomic.Uint64 - 不需要 CAS 是因为 SPSC 下无竞争;换成 MPSC 就必须对
tail做 CAS 循环重试 - 数组大小必须是 2 的幂,否则模运算无法用位与优化,且 CAS 失败率会上升
MPMC 场景下为什么必须处理 ABA 问题
多个生产者同时 push 时,都可能读到相同的旧 tail 值,各自构造新节点并尝试 CAS 更新。若其中一个成功,其余失败重试——但若此时消费者 pop 掉该节点又恰好复用了同一内存地址(GC 回收再分配),失败线程的 CAS 可能误判“成功”,导致链表断裂或重复入队。
Go 没有内置带版本号的指针(如 C++ 的 std::atomic<std::shared_ptr>),得手动拼装:
- 把节点指针和单调递增的版本号打包进一个
uintptr,用unsafe.Pointer转换 - 每次 CAS 前,先用
atomic.LoadUintptr读出当前值,拆包比对指针+版本号,再构造新值调用atomic.CompareAndSwapUintptr - 版本号存在
node.version字段里,每次节点被消费后自增(注意:不是每次 CAS 都增,仅在节点被释放重用前增) - 不处理 ABA 的典型表现:队列突然卡死、
Pop()返回 nil 但Len()显示非零、偶发 panic: “invalid memory address”
runtime.KeepAlive 在无锁队列里到底保什么
它不保节点数据,也不保队列结构体,只保「从原子操作发生到该函数调用之间」所有本应存活的局部变量引用。比如你在 CAS 后立刻把新节点赋给一个局部变量 newNode,然后就去干别的,Go 编译器可能认为 newNode 已死,提前触发 GC —— 即使该节点已被 CAS 写入链表头部。
- 正确姿势:在 CAS 成功后、函数返回前,插入
runtime.KeepAlive(newNode) - 错误姿势:只在函数末尾调一次,或放在 CAS 失败分支里 → 完全无效
- 漏掉
KeepAlive的现象:压测时偶发 panic: “concurrent map writes” 或 “invalid pointer found on stack”,其实是 GC 错杀了还在链表里的节点 - 别试图用
newNode = newNode这类空赋值替代 —— Go 1.20+ 已优化掉这种 trick
无锁队列最难的从来不是写 CAS 循环,而是厘清每个指针的生命周期边界、GC 可达性断点,以及不同 CPU 架构下内存序的隐式依赖。这些地方一松懈,bug 就只在 10w QPS 以上、持续跑 3 小时后才露头。

















