直接用 sync/atomic 实现无锁队列易崩溃,因 CAS 仅保障单指针原子性,而队列需 head/tail 协同更新,易读到中间态节点或 GC 回收的悬垂指针。

为什么直接用 sync/atomic 实现无锁队列容易崩溃
因为 CAS(CompareAndSwapPointer 或 CompareAndSwapUint64)只保证单个内存位置的原子性,而队列操作涉及 head/tail 两个指针协同更新。常见错误是:只保护 tail 更新,却让 head 移动时读到“中间态”节点(比如 next 指针还没被写入就已被读取),导致 panic: invalid memory address 或无限循环。
- 典型现象:
panic: runtime error: invalid memory address or nil pointer dereference出现在node.next.Load()或node.next.CompareAndSwap(nil, newNode) - 根本原因:Go 的 GC 不保证“已分配但未被任何指针引用”的对象立即不可见;如果旧节点被 GC 回收,而另一个 goroutine 还在通过 stale 指针访问它,就会崩
- 解决方案不是“加锁”,而是引入内存屏障 + 引用计数 + epoch 机制,或直接用成熟实现 —— 别自己造轮子
推荐方案:用 github.com/gcciv/golang-lockfree 替代手写
这个库实现了 Michael-Scott 算法的 Go 版本,并处理了 ABA 问题、内存重用和 GC 友好性。它不依赖 CGO,纯 Go,且经过大量压测验证。
- 初始化:
q := lfqueue.New(),返回的是*lfqueue.Queue,不是接口,避免 interface{} 带来的逃逸和反射开销 - 入队:
q.Enqueue(unsafe.Pointer(myData)),注意传入的是unsafe.Pointer,你要自己管理数据生命周期 - 出队:
ptr, ok := q.Dequeue(),ok为 false 表示队列空,不要对ptr做任何解引用,除非你确认它有效 - 关键限制:不能存
interface{}或含指针的 struct —— 否则 GC 无法追踪,会导致悬垂指针
如果你非得手写,必须绕过 Go 的 GC 风险
核心不是“怎么写 CAS”,而是“怎么让节点在被逻辑删除后,还能安全地被其他 goroutine 访问一段时间”。Go 没有类似 C 的 __atomic_thread_fence 级别控制,只能靠 runtime.KeepAlive 和显式内存屏障组合。
- 每个节点结构里必须包含
next unsafe.Pointer,且所有读写都走atomic.LoadPointer/atomic.StorePointer - 出队时,先 CAS 更新 head,再调用
runtime.KeepAlive(oldHead),防止编译器提前释放 oldHead 指向的内存 - 绝对不要在
Dequeue返回后立刻free节点 —— Go 没有free,你要用sync.Pool缓存节点,或用unsafe.Slice+syscall.Mmap手管内存(极少数场景) - 测试时一定要跑
go test -race和go run -gcflags="-m"看是否逃逸
性能对比:无锁队列真比 chan 快吗
在大多数业务场景下,不快,甚至更慢。Go 的 chan 在底层做了大量优化(如 sudog 复用、lock-free fast path),且语义清晰、GC 友好、调试方便。
立即学习“go语言免费学习笔记(深入)”;
- 只有当你明确观测到
chan成为瓶颈(pprof 显示chansend/chanrecv占 CPU >30%,且 goroutine 数量稳定在百级以下),才值得换 -
lfqueue的吞吐优势通常只在单生产者单消费者(SPSC)且关闭 GC 的极端压测中体现;MPMC 场景下,缓存行伪共享(false sharing)反而会拉低性能 - 一个常被忽略的坑:
lfqueue的Enqueue不阻塞,但你的业务逻辑可能需要背压 —— 它不会像chan那样天然支持select超时或默认分支
真正难的从来不是写对 CAS,而是判断“此刻是否真的需要它”。多数时候,chan + 合理 buffer size + 限速 goroutine,比无锁队列更稳、更易维护。










