Go 的 chan 不能作无界非阻塞队列,因其天然有界且写入会阻塞;需用原子操作+链表自实现,Enqueue 依赖 CAS 更新 tail,Dequeue 需哨兵节点防竞态,并警惕内存泄漏与 GC 压力。

为什么不能直接用 chan 做“无界非阻塞队列”
Go 的 chan 天然有容量限制,哪怕声明成 make(chan T, 0) 或 make(chan T),写入时若无协程立刻接收,就会阻塞或 panic(带缓冲但满时)。所谓“无界”,本质是要支持任意数量元素堆积而不阻塞生产者;“非阻塞”意味着 Enqueue 必须立即返回成功或失败,不能等消费者。
标准库没有现成类型,sync.Map 不适合队列语义,container/list 非并发安全——所以得自己造,核心是:链表 + CAS + 原子指针操作。
- 用
sync/atomic操作*node指针,避免锁竞争 - 节点结构必须是不可变的(入队后不改
next),否则多个 goroutine 同时修改会撕裂链表 - 不能依赖
unsafe.Pointer模拟原子指针(Go 1.19+ 已弃用),统一用atomic.Value或atomic.Pointer(Go 1.19+)
atomic.Pointer 怎么安全地实现 Enqueue
关键不是“加节点”,而是“把新节点挂到 tail 后,并更新 tail”。这需要两个原子操作:读当前 tail、CAS 更新 tail.next,再 CAS 更新 tail 自身。但存在 ABA 问题,所以实际要用“双指针”或“循环重试”策略。
推荐做法:每次 Enqueue 都尝试 CAS tail.next,成功后立即 CAS tail 指向新节点;失败则 reload tail 并重试。不需要锁,也不需要内存屏障手动干预(atomic.Pointer 的 CompareAndSwap 已含 acquire/release 语义)。
立即学习“go语言免费学习笔记(深入)”;
-
tail是一个*atomic.Pointer[node],不是裸指针 - 新节点的
next字段初始化为nil,避免被其他 goroutine 提前读到脏值 - 如果
CompareAndSwap返回 false,说明tail已被别人更新,必须重新 load 当前tail再试,不能直接 continue - 极端情况下可能重试多次,但平均开销远低于锁,且无死锁风险
为什么 Dequeue 比 Enqueue 更难写对
Enqueue 只动 tail,而 Dequeue 要同时改 head 和可能的 tail(空队列时),还要处理“只有一个节点”的竞态:A 在读 head,B 把它取走并设 head = nil,A 接着 dereference 就 panic。
工业级实现(如 golang.org/x/exp/concurrent 中的 Queue)用“dummy node”头哨兵 + “lazy tail update”规避这个问题:head 永远指向一个 dummy 节点,真实数据从 head.next 开始;tail 更新可以滞后,只要保证能遍历到末尾即可。
- 第一次
Dequeue时,如果head == tail,说明队列为空或只剩 dummy,需先 CAShead跳过 dummy,再读新head.next - 不要在
Dequeue中直接更新tail:它只在Enqueue中维护,Dequeue只负责推进head - 返回 nil 元素时,务必检查是否真为空,而不是靠
head == tail判断——因为 tail 可能还没追上来
别忽略 GC 压力和内存泄漏风险
无界队列最大的隐性成本不是 CPU,而是堆内存持续增长。每个节点都是堆分配对象,如果消费者长期 lag,节点不会被回收,GC 会越来越慢,最终 OOM。
这不是并发模型缺陷,而是设计契约问题:你得明确谁负责背这个锅。要么加监控告警(比如队列长度 > 10w 时打日志),要么加可选的 size limit + 拒绝策略(类似 Java 的 RejectedExecutionHandler),但这就不再是“无界”了。
- 不要用
runtime.SetFinalizer清理节点——finalizer 执行时机不可控,且会延长对象生命周期 - 如果业务允许丢数据,可在
Enqueue里加len > threshold的快速拒绝分支,避免走到 CAS 流程 - 用
pprof定期看heap_inuse_objects和gc_pause_usec,比压测更能暴露问题
真正难的从来不是写出 CAS 循环,而是想清楚:当队列涨到百万级节点时,你的服务还能不能健康心跳。


















