为什么 ConcurrentQueue<t></t>
不是纯无锁但表现接近
很多人以为
ConcurrentQueue<t></t>是完全 lock-free 的,其实它内部在**扩容 segment** 和**首次初始化 head/tail 节点**时仍会用到
Interlocked.CompareExchange配合少量自旋+回退逻辑,严格来说属于「无锁(lock-free)但非等待无关(wait-free)」。它的核心设计是把竞争拆到多个独立 segment 上,让绝大多数入队/出队操作只依赖
Interlocked原子指令完成,避免了
lock语句带来的线程挂起开销。
关键点在于:它不使用
Monitor.Enter或
lock(obj),所有共享状态更新都靠
Interlocked.Increment、
Interlocked.CompareExchange等实现——这是 lock-free 的底线。
ConcurrentQueue<t></t>
的 head/tail 分离与 ABA 问题规避
它用两个独立的 volatile 字段
_head和
_tail分别指向当前可消费/可插入的节点,避免单指针更新时的 ABA 冲突。每次
Enqueue尝试用
Interlocked.CompareExchange更新
_tail,失败就重试;
Dequeue同理操作
_head。但真正巧妙的是:它不直接修改节点的
Next引用,而是先用
Interlocked.CompareExchange把新节点挂到当前 tail 的
Next,再尝试推进
_tail—— 这样即使发生 ABA,也不会破坏链表结构。 节点
Next字段声明为
volatile,确保可见性 每个 segment 固定大小(默认 32),满后原子切换到新 segment,避免长链表遍历 没有使用
Unsafe或指针,纯托管代码,兼容 GC 和跨平台运行时
自己写 lock-free 队列前必须面对的三个硬伤
手写生产级 lock-free 队列远比看起来危险。.NET 的内存模型、JIT 重排序、GC 移动对象都会悄悄破坏你的假设:
volatile不能阻止所有重排序,某些场景需搭配
Thread.MemoryBarrier或
Interlocked指令 节点对象被 GC 回收后,其他线程可能还在读它的字段(dangling reference),
ConcurrentQueue用「节点永不删除」策略规避——你很难安全复现 ABA 问题在 .NET 中更隐蔽:不是整数被改回原值,而是引用被回收又分配到同一地址(尤其在 Server GC 下)
例如下面这段看似正确的入队逻辑:
var currentTail = _tail;
var newNext = currentTail.Next;
if (newNext == null && Interlocked.CompareExchange(ref currentTail.Next, newNode, null) == null)
{
Interlocked.CompareExchange(ref _tail, newNode, currentTail);
}实际会因
currentTail是局部副本而失效——你必须用
Interlocked.CompareExchange(ref _tail, ...)得到最新值,否则永远在过期节点上操作。
什么时候该用 Channel<t></t>
替代手写无锁队列
如果你要解决的是「高吞吐异步生产消费」,而不是「教学或性能极限压测」,
Channel<t></t>是更现实的选择。它底层基于
ConcurrentQueue<t></t>,但封装了背压、取消、完成状态等,且
WriteAsync/
ReadAsync在无竞争时几乎零分配。 同步场景用
ConcurrentQueue<t></t>,足够快也足够稳 需要限流或取消支持,直接用
Channel.CreateBounded<t>(size)</t>别碰
SpinWait+
Unsafe手写——除非你在写
System.Threading.Channels本身
真正难的从来不是原子操作本身,而是定义清楚「什么状态算一致」以及「谁负责清理中间态」。这两个问题没想透之前,所有
Interlocked调用都只是给崩溃加了随机延迟。
