“高性能并发队列”这个话题在 .NET 社区里从来不缺讨论,但绝大多数讨论到最后都会落到同一个结论:用ConcurrentQueue<T>。官方文档说它是无锁的,线程安全,性能优异。可我负责的一个多线程日志采集模块,就是在它上面翻车的。多个业务线程往队列里疯狂写结构化日志,一个消费线程异步落盘,跑一段时间后 gen0 GC 次数肉眼可见地往上飙,峰值时每秒上千次小回收,延迟抖动直接击穿业务容忍线。排查下来,问题不在“锁”,而在“分配”——ConcurrentQueue<T>的链式分段结构在高频写入下会不断产生新节点和分段,随之而来的是持续的 GC 压力。
于是就有了这个项目:ConcurrentNativeQueue<T>。这里的Native不是说 P/Invoke 非托管内存,而是“队列自身零托管分配”的意思。它是一个多生产者单消费者(MPSC)的无锁环形队列,用固定容量的预分配数组承载元素,借助 CAS 与内存屏障完成并发控制,入队和出队路径上不会产生额外的 GC 对象。适合严格控制 GC 抖动的高频任务投递、日志异步写入、事件分发这类场景。这篇内容会把设计思路、核心算法、完整实现和踩坑记录都摊开来讲,希望对正在做类似东西的人有帮助。
1. 为什么还需要一个“新”的队列实现
1.1 ConcurrentQueue 的软肋不在“锁”上
ConcurrentQueue<T>内部确实是无锁的。它基于分段链表实现,分段内部是一个数组缓冲区,多数入队操作只需要 Interlocked 操作,不需要管程锁。真正的问题出在链式结构面对“零分配”要求时撑不住。
分段内空间不足时,需要分配新的节点和分段。容量随负载增长时,分配会更明显。即使容量稳定,慢路径(比如段耗尽、节点重用)也会产生少量对象。更隐蔽的是枚举器、快照、ToArray()这些便捷方法几乎都会分配对象,在高频路径上不小心调用就是一场灾难。
链式结构的另一个问题是数据布局不连续。元素分散在不同的段和节点上,跨缓存行访问会让缓存命中率明显下降。对“写入为主、偶尔读取”的日志场景,吞吐量会随着并发线程数增长而退化,因为并发队列内部还要维护多个分段的可视性协调。
我没说它不好。通用场景下ConcurrentQueue<T>依然是最省事的默认选择。但如果你用 profiler 统计分配量,在每秒百万次写入的压力下,它会很诚实地告诉你 GC 压力从哪来。
1.2 MPSC 模型到底覆盖了哪些高频场景
多生产者单消费者是“单向数据流”里最典型的模型。我实际踩过的场景至少有三个:
- 日志采集:多个业务线程产生日志条目,一个日志线程做异步序列化,写文件或网络。
- 游戏引擎主线程任务投递:工作线程完成渲染、物理或模拟后,把结果或事件喂给主线程,主线程只在固定位置消费。
- 网络事件分发:多个连接各自的接收线程把消息投递给唯一的事件消费循环,消费端按顺序处理。
这些场景的共同点是消费者唯一。消费者唯一意味着出队路径可以做到极简——不需要 CAS,不需要锁,只需要读序号、读数据、推头部。所有的无锁复杂性都可以集中在入队端。
MPSC 的“单消费者”限制在这里不是缺陷,而是架构已经天然保证的约束。强行用ConcurrentQueue<T>虽然也能得到线程安全,却未必能得到稳定的延迟和零 GC。
1.3 环形缓冲为什么是最优解
面对 MPSC 高频投递,环形缓冲几乎是自然选择。相比链表:
- 内存一次性预分配,生命周期内不产生新节点,天然符合零 GC 目标。
- 数组物理连续,CPU 缓存友好,生产者和消费者顺序读写时命中率高。
- 槽位下标用位掩码计算,比链表指针跟随便宜得多。
- 容量固定,“队列是否满”的判断变得简单,不需要维护多余状态。
代价是容量必须提前估计。估小了队列会频繁满,估大了空转时内存占用偏高。解决思路是让调用方传入容量,构造时自动向上取 2 的幂。后面会细讲估算方法。
零 GC 压力的核心就是预分配。T[]数组的一次性分配是可以接受的,后续所有操作都复用同一片数组,不再产生新托管对象。
2. 核心算法的设计与取舍
2.1 无锁队列的路线图:为什么选择“序号发布”
无锁队列的实现大致有几条路线:
- 基于节点链表,CAS 推进头尾指针。这是教科书里最常见的变体。
- 基于环形缓冲和计数器,用
Interlocked.Increment获取独占槽位。 - 基于环形缓冲与“序号发布”机制,每个槽位附带一个发布序号。
路线一就是经典的 MPSC 链表思路,正确性容易证明,但生命周期内要管理节点复用。如果要做零分配,就得再做一个预分配节点池,复杂度和隐藏的坑都会上升。
路线二在不少项目里见过,最常见的坑是容易把“推进 tail”和“数据发布”混为一谈,导致消费者读到尚未完成的写入。
路线三是我最终采用的方案。核心区别是:入队拆成两个阶段——先用 CAS 推进逻辑尾序号,拿到独占槽位,再写入元素,最后用Volatile.Write向该槽位对应的发布序号数组写入本次序号。消费者端通过比较期望序号与槽位发布序号,判断数据是否就绪。
2.2 为什么不能“先写数据、再推 tail”
你可能会想:让生产者先把元素写进自己的槽位,再 CAS 推进tail,这样消费者看到的就都是已写好的数据。听起来合理,但并发下会出错。
假设两个生产者 A、B 同时入队。A 获得序号 5,B 获得序号 6。A 的元素还没写入槽位 5,B 的元素已经写入槽位 6,并且 B 的 CAS 把tail从 5 推进到了 7。消费者读tail=7后,会从槽位 5 开始消费,但槽位 5 还是旧数据或默认值——消费者就错误消费了“还没来得及写”的数据。
“先推 tail、再写数据”同样有问题:tail提前暴露给消费者后,消费者可能看到 tail 已推进但槽位内容还没就绪。
所以必须引入独立的发布状态。用long[] _sequence记录每个槽位已经发布到的序号。消费者只有看到_sequence[index]等于期望序号时,才真正读取元素。这是最稳妥的简化方案,也是 Disruptor 那套思想在 .NET 里的一个轻量落地。
2.3 伪共享(False Sharing)必须提前处理
无锁队列性能好不好,很多时候瓶颈不在指令本身,而在缓存一致性协议。
_head和_tail是两个会被不同线程频繁读写的长整型字段。消费者线程每次出队推进_head,生产者线程每次入队读_head判断是否满。如果这两个字段落在同一条 64 字节缓存行上,任何一方写入都会导致整条缓存行在其他核心上失效。生产者和消费者来回重载,性能可能掉一个数量级。
解决办法是给两个字段分别“补位”。在 .NET 里最简单的做法是在字段之间插入若干long填充字段。一条缓存行通常 64 字节,64 位平台上 8 个long正好占满。我在_head后面加 7 个long,_tail后面也加 7 个,保证它们落在不同的缓存行。
private long _head; private long _headCachePadding1; private long _headCachePadding2; private long _headCachePadding3; private long _headCachePadding4; private long _headCachePadding5; private long _headCachePadding6; private long _headCachePadding7; private long _tail; private long _tailCachePadding1; private long _tailCachePadding2; private long _tailCachePadding3; private long _tailCachePadding4; private long _tailCachePadding5; private long _tailCachePadding6; private long _tailCachePadding7;_sequence数组如果追求极致,也可以让每个元素独占一个缓存行,但那样内存占用膨胀 8 倍,容量大时反而伤害局部性,通常不值得。
3. 动手实现 ConcurrentNativeQueue
3.1 字段布局与数组初始化
完整实现里我把类设计为sealed class,泛型参数 T。虽然叫 Native 队列,但并不追求 P/Invoke 或非托管内存。原生指的是“不依赖托管堆上的动态对象,数组预分配后不再产生新分配”。
初始化有两个关键点:
- 容量自动向上取 2 的幂。取幂的好处是下标计算可以用
pos & _mask代替取模,位运算远比“除以任意数取余”便宜。 _sequence数组的初始值必须小于每个槽位第一次被消费者期望的序号。最简单的方式是把第 i 个槽位初始化为i。消费者第一次期望的序号是head + 1 = 1,对下标 0 来说0 < 1,对下标 1 来说1 < 2,一定不会相等。
public sealed class ConcurrentNativeQueue<T> { private readonly T[] _buffer; private readonly long[] _sequence; private readonly int _capacity; private readonly int _mask; private long _head; private long _headCachePadding1; private long _headCachePadding2; private long _headCachePadding3; private long _headCachePadding4; private long _headCachePadding5; private long _headCachePadding6; private long _headCachePadding7; private long _tail; private long _tailCachePadding1; private long _tailCachePadding2; private long _tailCachePadding3; private long _tailCachePadding4; private long _tailCachePadding5; private long _tailCachePadding6; private long _tailCachePadding7; public ConcurrentNativeQueue(int capacity) { capacity = ToPowerOfTwo(capacity); _capacity = capacity; _mask = capacity - 1; _buffer = new T[capacity]; _sequence = new long[capacity]; for (int i = 0; i < capacity; i++) { _sequence[i] = i; } _head = 0; _tail = 0; } public int Capacity => _capacity; private static int ToPowerOfTwo(int value) { if (value <= 0) { throw new ArgumentOutOfRangeException(nameof(value)); } value--; value |= value >> 1; value |= value >> 2; value |= value >> 4; value |= value >> 8; value |= value >> 16; return value + 1; } }容量取幂这块不要偷懒直接用循环乘 2,上面的位运算版本无论传入多大值都只做几次移位和或操作,效率稳定。
3.2 TryEnqueue:CAS 推进逻辑尾部,Volatile 发布数据
入队路径的完整过程:
- 读当前
_tail快照tail。 - 计算
next = tail + 1,检查next - _head > _capacity。超了就返回 false,表示队列已满。这个检查必须在 CAS 之前,否则可能出现越界覆盖。 - 用
Interlocked.CompareExchange尝试把_tail从tail更新为next。CAS 成功的那一刻,当前线程独占序号tail对应的槽位。 - 写入元素到
_buffer[tail & _mask]。 - 用
Volatile.Write把next发布到_sequence[tail & _mask]。
public bool TryEnqueue(T item) { while (true) { long tail = Volatile.Read(ref _tail); long next = tail + 1; // 队列已满:head 没有及时消费,tail 领先 head 达到 capacity if (next - Volatile.Read(ref _head) > _capacity) { return false; } // 多个生产者竞争推进 tail,只有一个能成功 if (Interlocked.CompareExchange(ref _tail, next, tail) == tail) { int index = (int)(tail & _mask); _buffer[index] = item; // release 语义:确保上面的写入对消费者可见 Volatile.Write(ref _sequence[index], next); return true; } // CAS 失败,说明有其他生产者抢先一步,自旋重试 Thread.SpinWait(1); } }Volatile.Write绝对不能省。它带 release 语义,保证“槽位元素的写入”在“序号发布”之前对其他核心可见。省略它,消费者可能在_sequence[index]已经变成next后仍然看不到完整的元素内容,这在弱内存序平台(比如 ARM)上是真实会发生的问题。
CAS 失败后的自旋次数不要写死。Thread.SpinWait(1)只是一次短暂 pause,如果竞争激烈,多次重试后应该切换为Thread.Yield()或Thread.Sleep(0),避免长时间自旋烧 CPU。我一般会在本地用一个计数器,超过若干次后让步。
3.3 TryDequeue:单消费者的快速路径
消费者端假设同一时刻只有一个线程调用:
- 读
_head,检查head + 1是否超出现有_tail。如果head == tail,说明没有新元素,返回 false。 - 注意
tail可能已经推进,但生产者元素还没发布。所以必须检查_sequence[index]是否等于期望序号head + 1。 - 相等说明数据已就绪,读取元素,推进
_head。
public bool TryDequeue(out T item) { long head = Volatile.Read(ref _head); if (head + 1 > Volatile.Read(ref _tail)) { item = default!; return false; } int index = (int)(head & _mask); // 数据可能尚未发布,严格来说队列“非空”但不可消费 if (Volatile.Read(ref _sequence[index]) != head + 1) { item = default!; return false; } item = _buffer[index]; // 消费者独占,无 CAS 需要 Volatile.Write(ref _head, head + 1); return true; }出队路径上没有Interlocked,只有两次Volatile.Read和一次Volatile.Write。这就是 MPSC 相对 MPMC 白捡的性能红利,不需要对_head做原子自增,也不需要在多个消费者之间串行化。
如果同一个队列允许两个及以上消费者同时调用
TryDequeue,_head的推进就会竞争。请务必将同一时刻的消费者限制为一个,或者改造为 CAS 推进_head。
3.4 容量选择与满队列的语义约定
容量选择直接决定队列是否经常满。我的经验是:按业务峰值估算每秒最大入队数P和消费端单次处理延迟D(秒),容量建议取不小于P * D * 2的二次幂。比如峰值每秒 500 万次入队、消费单次 1 微秒,那5000000 * 0.000001 * 2 = 10,往上取 16 就够;如果消费延迟波动大,可以取 64 或 128。太小会频繁触发满队列,导致生产者丢数据或忙等。
_mask的计算依赖容量必须是 2 的幂,这是实现约束。传入非 2 的幂时构造器自动向上取整,取整后的容量可能明显大于传入值,内存敏感场景要留意。
4. 再进一步:零 GC 的边界与进阶技巧
4.1 入队路径上的隐形分配:闭包、Lambda 与装箱
很多自称“零分配”的队列,真正的问题不出在队列主体,而出在调用方。
比如把 T 直接实例化为object或接口类型时,每次传参都可能引入装箱。在TryEnqueue里使用 Lambda 回调、匿名类型作为入队元素,底层会为闭包分配DisplayClass对象。
我给TryEnqueue和TryDequeue都标了[MethodImpl(MethodImplOptions.AggressiveInlining)],减少不必要的调用开销。但对 JIT 来说,只有在调用点明确使用值类型 T 时,才能完全消除额外分配。
真正的“零 GC 压力”不是 T 本身不分配,而是“队列操作本身不分配”。T 为结构体且不含引用字段时效果最好;T 为引用类型时,队列不会额外分配,但调用方如何创建对象是另一回事。
4.2 出队后要不要清理槽位内存
这个问题我在第一次实现时吃过亏。如果 T 是引用类型(比如string、byte[]),出队后_buffer[index]仍然保留这个引用。旧对象已经没人用,只要数组还持有引用,GC 就不能回收它。队列在无形中延长了对象生命周期,长时间运行会出现“应该能回收却回收不掉”的对象,内存稳步增长。
所以我加了清理动作:出队后把槽位置为default(T)。对值类型,这个写入几乎零成本;对引用类型,它能为 GC 提供准确的引用图。
item = _buffer[index]; _buffer[index] = default!; Volatile.Write(ref _head, head + 1);赋值要在推进_head之前。否则另一个生产者会认为该槽位已经空闲,可能在你还在读旧值时覆盖数据。单消费者模式下不容易发生,但保持这个顺序更稳妥。
4.3 阻塞式 Dequeue 的扩展方向
TryDequeue返回 false 只是“当前没有已发布数据”的瞬时状态。在真正的日志消费线程里,我们希望等待新数据,而不是空转轮询。一般有两种方案:
- 自旋等待:消费者线程用
SpinWait循环调用TryDequeue,直到成功。CPU 占用略高,但延迟最低。 - 信号量唤醒:入队成功后触发轻量信号量,让消费线程无数据时阻塞。信号量本身会分配,但在阻塞路径上分配一次可以接受。
我实际用的是自旋为主、让步为辅的策略。消费者线程短暂自旋后如果仍无数据,就Thread.Yield()让出执行权,避免长时间空闲时烧满一个核。
4.4 从 MPSC 扩展到 MPMC 的改造点
如果未来需要支持多消费者,改动并不复杂:把TryDequeue里推进_head的Volatile.Write改成Interlocked.CompareExchange,满/空判断也统一用 CAS 循环。_sequence的发布-检验机制天然支持任意顺序的读者,多消费者只需要解决“谁拿到这个槽位的消费权”。
不过要注意,扩展成 MPMC 后_head的 CAS 竞争会让出队路径成本明显回升,原本“单消费者不抢锁”的性能优势会被削弱。除非业务明确需要,否则不建议轻易扩展。
5. 性能实测与实际场景建议
5.1 一个简单的压测方法
我测的时候没用什么复杂框架。固定容量比如 4096,起 N 个生产者线程循环入队固定数量的元素,一个消费者线程循环出队,最后统计总吞吐与 GC 分配。测量分配用GC.GetAllocatedBytesForCurrentThread(),每个线程入队前记录一次、入队后结算一次,不引入额外分配。
在某 8 核 x86-64 机器、.NET 8 环境下,单生产者单消费者时,这个队列和ConcurrentQueue<T>的吞吐差距还不算夸张,主要优势体现在分配量为 0。但生产者线程数来到 2 到 4 时,传统队列的分段维护和高频分配会让吞吐明显下滑,这个实现因为预分配和序号发布,吞吐非常平稳,大约能差 2 到 3 倍,差异随并发度扩大而扩大。
这些数据只是参考,不同硬件和容量下结果差异很大。真正重要的是原理上的差别:避免了链式结构扩容、避免了每次入队的节点分配、把消费者从 CAS 中解放出来。
5.2 哪些场景应该用这个队列
- 生产路径不能出现 GC 抖动:比如高频行情投递、游戏引擎的每帧事件队列。
- 消费线程唯一:架构上已经是一个明显的“单消费者”模式。
- 队列容量可估算:日志、事件、网络包,通常都能通过监控确定一个合理的上边界。
- 愿意接受固定容量上限:如果生产者长时间超过消费者,队列会持续返回满,需要上层做背压或丢弃策略。
5.3 哪些场景不要用
- 消费者数量不固定,或者未来可能扩展为多个消费者。
- 队列容量无法估算,峰值波动巨大,需要无界增长。
- 对延迟不敏感,希望代码越简单越好,直接锁队列或
ConcurrentQueue<T>更稳。 - 元素很大(比如
byte[]),把它们丢进固定数组可能浪费内存且伤害缓存,此时更适合传递引用。
6. 常见问题与排查技巧实录
6.1 TryDequeue 返回 false 但队列明明有新数据
这是最容易懵的点。tail推进并不代表数据已经发布。生产者 A 已经 CAS 推进了tail,但还没把元素写入槽位、也没发布序号,消费者此时head + 1 <= tail成立,但_sequence检查不通过。在代码设计上,TryDequeue返回 false 的语义是“当前没有已发布的可消费数据”,而不是“队列为空”。
如果需要强语义,可以提供一个Dequeue阻塞方法,在序号检查失败时继续自旋等待。我一般会提供两个 API:TryDequeue给上层做非阻塞轮询,Dequeue给消费者线程做阻塞读取。
6.2 多消费者一运行就“丢数据”或顺序错乱
不是算法错了,是这个队列根本没有多消费者支持。TryDequeue只保证单消费者安全;当两个线程同时发现head + 1可用时,它们都会读取同一个元素,都会返回成功,结果就是同一个元素被消费两次。最容易看到的现象是“处理次数大于投递次数”。
排查时打开序号检查,给每个元素打上递增编号,很快就能定位到重复消费的位置。要让队列支持多消费者,把_head推进改成 CAS,满判断逻辑也挪进同一个 CAS 循环。
6.3 Release 下正常,Debug 下偶尔异常
无锁代码对编译器重排和 CPU 乱序非常敏感,Debug和Release的 JIT 优化不同,确实可能出现差异。排查时第一件事是检查所有跨线程共享变量的读写是否都走了Volatile或Interlocked。
我踩过的一个坑是,早期版本消费者读_head用的是普通字段读写,没加Volatile.Read。x64 上一切正常,在 ARM64 机器上就偶发读到旧值。所以别拿 x86 的强内存序赌平台,任何无锁实现都要规范使用Volatile.Read/Write和Interlocked。
6.4 队列容量到底选多大
容量选择建议先监控业务峰值。比如生产端每秒入队 200 万、消费端单事件处理 2 微秒,队列需要缓冲 400 个事件以上。乘一个 2 倍安全系数得到 800,向上取 2 的幂就是 1024。如果生产端偶尔出现几十毫秒的突发,容量要按突发窗口重新计算。
容量过大也有代价。初始化要一次性分配T[]和long[]两个数组,如果 T 是 32 字节的结构体,2 万元素可能带来几十 MB 占用,需要综合评估。
最后说一句,调试这类代码时,光靠断点基本抓不到问题。建议用多核机器配dotnet-trace或 ETW 采样看线程状态,并在入队/出队的发布点临时插入序号递增校验,能最快发现发布顺序被破坏的现场。我最初实现里有一个低概率“读旧值”的问题,就是靠全量序号校验抓出来的。这套队列后来被我复用到自研日志通道和游戏主线程事件投递组件上,压测时 GC 分配稳定归零。如果你也想做类似的高频、低抖动队列,建议先按 MPSC 加环形缓冲加序号发布的思路搭一个最小版本,跑通压测后再逐步加上缓存行填充和阻塞 API。这些坑踩过一遍,回头再看别的无锁实现,都会轻松很多。