做并发编程的人应该都经历过一个尴尬期:还没开始优化业务逻辑,先被队列的锁竞争和 GC 分配拖住了后腿。用 .NET 自带的 ConcurrentQueue<T> 虽然省心,但跑在需要极致吞吐的场景里,它天生的多生产者多消费者定位反而成了性能包袱。这篇文章我想和你分享一个我实际在项目里落地过的实现——ConcurrentNativeQueue<T>,一个完全无锁、零 GC 压力的 MPSC 原生队列,用它替换掉部分 ConcurrentQueue<T> 之后,我这边的高频日志采集管道吞吐提了一倍多,持锁时间和暂停时间都明显下降。如果你也在维护多线程生产、单线程消费的数据通路,这篇文章应该能帮上忙。
这个项目的核心诉求很简单:在 .NET 环境里做一块原生内存承载的有界 MPSC 队列,支持任意个生产者并发入队,消费者只有一个线程可以出队,全程没有 lock、没有 Monitor、没有 Interlocked 之外的原子操作,并且入队出队都不产生托管堆分配。它适合做消息总线背板、线程间命令分发、日志缓冲这类对延迟敏感、对吞吐有硬要求的场景。比起 ConcurrentQueue<T>,它把"通用性"换成了"极致单向性能",所以使用前提是你确实只有一个消费者线程。
下面我把从设计到落地的完整过程拆开讲,包括数据结构怎么选、无锁原子操作怎么编排、内存屏障为什么不能省,以及我在实际跑压测时踩过的几个坑。
1. 为什么需要一个新的并发队列
1.1 ConcurrentQueue 的通用性代价
很多人没意识到,ConcurrentQueue<T> 的定位是"多生产者多消费者安全",这个通用性是通过内部复杂的分段链表结构和多套原子操作换来的。每个生产者入队时要竞争尾指针,每个消费者出队时要竞争头指针,即便当前只有一个生产者和一个消费者,代码路径上仍然要走过完整的 CAS 分支。更麻烦的是,它内部的分段(Segment)在扩容时会产生新的段对象,元素如果跨段存储,还会增加额外的引用链路。实际用 profiler 看,高吞吐下 ConcurrentQueue<T> 的入队操作里有不小比例的时间花在段切换和引用遍历上。
这还不是最要命的。ConcurrentQueue<T> 接受任意 T,引用类型元素的入队会让你不断产生新的 Node<T> 对象,这些对象最终落在 gen0,触发 GC 的频率肉眼可见地上升。GC 一跑,所有线程都跟着抖,对低延迟业务来说这就是灾难。
1.2 MPSC 场景在现实里其实非常普遍
我最初是在做游戏服务器的战斗日志管线和独立小程序的指标采集时才意识到:我们生产的代码里,大量并发模型是"多线程产出、单线程消费"。比如:
- 多个战斗线程把技能、伤害、击杀日志投递到一个队列,由唯一的 IO 线程批量刷盘;
- 网络收包后,多个 Session 处理线程把解析好的消息丢进队列,由单一的逻辑主线程统一处理;
- 遥测数据采集器中,多个监控线程上报指标样本,由后台聚合线程消费并推送。
这些场景的共同特征是:消费者只有一个,且消费逻辑本来就是顺序化的。这种情况下再用 MPMC 队列,等于给所有参与者都套上了不必要的竞争和同步开销。如果让消费者独占头指针、多个生产者只需要竞争尾槽位,整个并发模型会轻快很多。
1.3 "零 GC 压力"到底意味着什么
先说清楚,这里说的零 GC 压力不是"完全不让 GC 运行",而是指队列本身在绝大多数操作路径上不产生托管堆分配、不制造新的可达对象引用。我的做法是把队列的缓冲区直接放到非托管内存(native memory)上,用指针操作元素地址,元素本身用 unmanaged 值类型承载。这样一来:
- 入队出队不创建任何对象,gen0 的分配次数近似为零;
- 队列缓冲区不在 GC 堆上,GC 扫描根对象时不会遍历到队列内容,长队列也不会拖慢标记阶段;
- 值类型元素直接读写原生内存,没有装箱拆箱。
如果你的 T 是引用类型,严格意义上是做不到"零 GC 压力"的,至少元素引用本身会被 GC 跟踪。所以这个实现的类型约束我直接锁死为 unmanaged,这也是命名里叫 Native Queue 的原因之一。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 设计思路:从选型到核心原理
2.1 无界链表还是有界环形缓冲区
无界链表的优势是理论容量无限,但每个节点在入队时都要分配,完全违背零 GC 的目标。就算用对象池做节点复用,池本身的并发访问又是一层竞争。对于高吞吐场景,我更倾向于有界环形缓冲区——容量固定、槽位复用、无动态分配。
有界带来一个明显的业务问题:队列满了怎么办?处理方式有两种,一种是入队接口返回失败,由调用方决定重试或丢弃;另一种是在入队端自旋等待消费者腾出空间。我实现里同时提供了 TryEnqueue 和带自旋的 Enqueue 重载,默认用 TryEnqueue 让上层业务做取舍,因为无锁队列配合不确定的自旋等待,会让应用层的延迟行为变复杂。
环形缓冲区的容量必须是 2 的幂,这样索引对容量的取模可以直接用位与运算完成,省掉 % 运算那几十个周期的开销。这不是洁癖,压测数据里高频路径上少一个除法指令,整体 P99 延迟都会有可见改善。
2.2 多生产者如何协调:CAS 分配槽位
有界环形缓冲区的头尾索引模型是这样的:
_head:下一个待消费的槽位下标,只由消费者线程读写,不需要原子操作;_tail:下一个待写入的槽位下标,所有生产者并发读改写,必须用原子操作。
生产者入队时的核心动作是"先把 tail 占住,确保这个槽位归我,然后写入数据,最后把状态位置为就绪"。占槽位我用的是 Interlocked.CompareExchange 循环,伪代码如下:
csharp复制while (true)
{
int tail = Volatile.Read(ref _tail);
int head = Volatile.Read(ref _head);
if (tail - head >= _capacity)
{
return false; // 队列满
}
if (Interlocked.CompareExchange(ref _tail, tail + 1, tail) == tail)
{
// 槽位分配成功
break;
}
}
这里的 CAS 是"比较再交换",只有读到的 _tail 仍是 tail 时,才会把它变成 tail + 1。如果有其他生产者抢先了一步,CAS 会失败,我们重新读 tail 再试。
为什么不直接用 Interlocked.Increment?因为 Increment 是无条件把 _tail 加一,对满队列来说,如果容量检查在后,就会造成槽位序号被白白占用,消费者还没追上来,生产者已经越过了整个环形区。CAS 方案能做到"先确认容量够,再原子占位",语义最安全。
注意这里读 _head 用的是 Volatile.Read,而不是普通读。因为 _head 是消费者在另一个线程写的,如果不加 volatile 语义,生产者可能在一个陈旧值上做判断,长时间读不到最新状态。虽然即使读到旧值也只是误判为满,不至于写到已经超过 head 的位置,但为了避免编译器优化造成更激进的乱序,这个 volatile 读不能省。
2.3 消费者为什么没有锁
MPSC 最大的红利在消费者侧。_head 只有消费者一个人碰,所以出队时不需要任何 CAS,只需要顺序读 _head、读状态位、读元素、推进 _head。这整套操作没有任何竞争,也不会有别的线程来抢消费者的索引。在 x86 上就是连续几次普通 load/store,延迟极低。
当然,消费者和生产者之间仍有跨线程协作,所以 _head 的推进和状态位的读取必须用 volatile 语义,保证缓存一致性系统能把最新值正确暴露出来。但在"锁"这个层面,消费者确实一条 lock 前缀指令都不需要。
2.4 内存屏障与可见性(这节讲为什么能安全)
很多人写无锁代码只盯着原子函数,却忽略了内存序问题。这里最关键的一个约束是:生产者的写入顺序必须让消费者可识别。
想象一下生产者 P1 拿到了槽位 index=5,它先写了元素数据 _buffer[5] = item,然后把状态位置为就绪 _state[5] = 1。如果 CPU 或编译器把这两步乱序了,消费者先看到 _state[5] == 1,再去读 _buffer[5] 就可能读到旧数据,队列就坏了。
解决方案就是靠内存屏障制造"发布"语义:
- 生产者写元素数据后,执行
Volatile.Write(ref _state[5], 1); - 消费者读
_state[5]用Volatile.Read。
在 .NET 的运行时模型里,Volatile.Write 自带 Release 语义(它之前的所有读写都不能被越过它乱序到后面),Volatile.Read 自带 Acquire 语义(它之后的所有读写都不能被乱序到它前面)。生产者这边,数据写入"先于"状态置位;消费者这边,看到状态置位后,"才能"去读数据。这一对操作凑成了 happens-before,数据可见性就有了保证。
另一个容易踩的点是消费者的 _head 推进。消费者读出数据并处理完后,要把 _state[index] 清回 0,然后 Volatile.Write(ref _head, head + 1)。这个推进动作必须排在状态清 0 之后,否则生产者在槽位还标记为"旧数据就绪"时又写入新元素,消费者可能把你刚写入的内容当作旧内容重复消费或直接漏掉。
3. 核心实现与关键代码
3.1 类型约束与内存布局设计
为了让队列接受指针操作,类型约束用了 where T : unmanaged。这个约束排除了引用类型,但保留了对结构体、枚举、指针、原生整数类型等一切非托管类型的能力。你可以在结构体里嵌固定字节数组来承载任意自定义数据,灵活性其实很大。
内存布局上,我选择了一块连续的非托管内存,前半部分是元素缓冲区,后半部分是状态数组:
csharp复制public sealed unsafe class ConcurrentNativeQueue<T> : IDisposable where T : unmanaged
{
private readonly T* _buffer;
private readonly int* _state;
private readonly int _capacity;
private readonly int _mask;
private int _head;
private int _tail;
}
分配时用 .NET 6 之后的 NativeMemory.AllocZeroed,一次性分配 capacity * sizeof(T) + capacity * sizeof(int) 字节:
csharp复制nuint bufferBytes = (nuint)capacity * (nuint)sizeof(T);
nuint stateBytes = (nuint)capacity * (nuint)sizeof(int);
_buffer = (T*)NativeMemory.AllocZeroed(bufferBytes + stateBytes);
_state = (int*)((byte*)_buffer + bufferBytes);
AllocZeroed 会把内存清零,所以初始状态位全是 0,正好表示"空缺"。容量校验放在构造器里,如果不是 2 的幂直接抛异常,避免运行期才暴露错误。
3.2 Enqueue:CAS 抢槽 + 发布写入
完整入队代码我贴在下面,这个版本没有做缓存行填充等极客优化,核心逻辑清晰可读:
csharp复制public bool TryEnqueue(T item)
{
while (true)
{
int tail = Volatile.Read(ref _tail);
int head = Volatile.Read(ref _head);
if ((tail - head) >= _capacity)
{
return false;
}
if (Interlocked.CompareExchange(ref _tail, tail + 1, tail) == tail)
{
int index = tail & _mask;
_buffer[index] = item;
Volatile.Write(ref _state[index], 1);
return true;
}
}
}
这里有个小细节:_buffer[index] = item 是普通的写,不是 volatile 写。因为紧接着的 Volatile.Write(ref _state[index], 1) 已经带了 Release 屏障,它会阻止更早的数据写被重排到它后面。编译器看到这里存在 volatile 写,会生成正确的屏障指令,所以我们不需要手动给数据写也加 volatile。
index 是通过 tail & _mask 算出来的,因为容量是 2 的幂,这个位与等价于 tail % capacity。
3.3 TryDequeue:单消费者独占头指针
消费者侧的代码更短:
csharp复制public bool TryDequeue(out T item)
{
int head = Volatile.Read(ref _head);
int index = head & _mask;
if (Volatile.Read(ref _state[index]) != 1)
{
item = default;
return false;
}
item = _buffer[index];
Volatile.Write(ref _state[index], 0);
Volatile.Write(ref _head, head + 1);
return true;
}
注意我先把 _head 读到局部变量,后面的索引、状态判断、元素读取、状态清 0、head 推进都基于这个 head 值。因为消费者只有一个人,_head 在自己线程里不会发生外部变化,这样读一遍就够了,不需要每次取都用 volatile,也能避免多次 volatile 读带来的指令开销。
为什么要先 Volatile.Write(ref _state[index], 0) 再推进 _head?因为如果不先清状态,生产者在环形回绕后看到一个仍为 1 的旧状态位,会误以为槽位处于就绪态,可能直接跳过后面的发布写入。而且清 0 和推进 head 之间必须有正确顺序——生产者的容量判断同时依赖 head 和 state,也就是说它要能确认"这个槽位已经不属于消费者了,可以重新入队"。
3.4 原生内存的分配与释放
既然用了 NativeMemory.AllocZeroed,Dispose 里就要对应地释放:
csharp复制public void Dispose()
{
if (_buffer != null)
{
NativeMemory.Free(_buffer);
_buffer = null;
}
}
这里有个容易忽略的问题:如果队列里还存着结构体元素,而结构体里又包含指向托管对象的引用(虽然 unmanaged 约束已排除这种情况),或者结构体有需要释放的非托管资源,直接释放内存会绕过元素自身的清理逻辑。使用边界要约束好:存入队列的元素要么是纯数据 POD,要么由外部负责其生命周期。Dispose 前最好确保没有其他线程还在入队出队,否则并发访问到已释放的内存,行为是未定义的。
如果你的目标框架还不支持 NativeMemory,可以用 Marshal.AllocHGlobal 配合 Marshal.FreeHGlobal,只是 AllocHGlobal 返回 IntPtr,要自己转成指针,代码稍微啰嗦点。
4. 性能实测:与 ConcurrentQueue 的对比
4.1 Benchmark 设计
我用 BenchmarkDotNet 在 .NET 8 上做了压测,机器是 i7-12700K,关闭超线程,固定到 P 核。基准场景固定一个消费者线程,生产者数量分别测了 1、2、4 个。每个生产者循环向队列写入 100 万个递增的 long 值,消费者读取后做简单的累加校验,避免编译器把消费逻辑优化掉。
对比对象是 System.Collections.Concurrent.ConcurrentQueue<long>,以及一个基础的 lock + Queue<long> 版本(锁队列为了公平比较,也把容量预先分配好)。测试指标是每百万次操作的耗时和每秒吞吐量。
这里要说明,BenchmarkDotNet 对多线程 Benchmark 支持有限,所以我实际采用的是外部控制线程的方式计时,用 Interlocked 计数来同步开始和结束,测出来的数据反映的是真实管道性能,不是单线程 microbenchmark。
4.2 结果解读
直接放我测试机上的一组代表性数据:
| 场景 | ConcurrentQueue | lock + Queue | ConcurrentNativeQueue |
|---|---|---|---|
| 1 生产者,耗时 ms | 152 | 340 | 108 |
| 2 生产者,耗时 ms | 214 | 428 | 139 |
| 4 生产者,耗时 ms | 328 | 561 | 201 |
单生产者场景下,ConcurrentNativeQueue 比 ConcurrentQueue 快了约 40%,比 lock 版快了约 3 倍。生产者增加后,无锁队列的优势依然明显,4 生产者时吞吐大约是 ConcurrentQueue 的 1.6 倍。
更明显的差异在分配上。ConcurrentQueue 在入队 long 时不会分配节点(值类型直接存在段内),但如果 T 是引用类型或大结构体,分配会非常扎眼。我另外测了 T 为 string 的版本,ConcurrentQueue 每入队一个元素会产生一个 Node 对象的 gen0 分配,而 ConcurrentNativeQueue<long> 完全不产生任何分配。
4.3 什么时候不该用它
有界是无锁队列最大的限制。如果你的业务高峰期入队速度会长时间超过消费速度,队列会频繁返回"满",这时候需要上层做背压策略,或者干脆换成无界实现。另外,如果你的 T 必须是引用类型,比如字符串、DTO 对象,这个原生队列并不适用——强行塞引用类型进非托管内存,底层语义是灾难。
还有一个分寸问题:如果业务本身已经有成熟的背压和监控方案,ConcurrentQueue 也不一定会成为瓶颈。只有在 profiling 明确显示队列层是热点时,才值得引入这么底层的实现。否则收益有限,维护成本还高。
5. 踩坑记录与排查技巧
5.1 队列"假空"还是真空
一个非常容易困惑的行为是:TryDequeue 返回 false 并不代表队列一定为空,它可能只是表示"当前头部槽位还没有生产者发布完成"。因为多生产者环境下,生产者 A 占住了 tail=5,但还没写入 _state[5],消费者这时读 _state[5] 得到 0,于是返回 false。
解决方法是区分两种状态:
false表示当前没有可消费的就绪元素;IsEmpty属性需要更严格,要同时满足_tail == _head且对应状态位为 0,才算真空。
如果你在业务里有"队列空就退出消费循环"的逻辑,只靠 TryDequeue 返回 false 会提前退出,造成后面的数据永久滞留。正确的消费循环应该是:先尝试出队,如果 false,再用 IsEmpty 判断是否整体结束消费。
5.2 缓存行伪共享的威力
最初版本里,_head 和 _tail 作为普通字段紧挨着,跑多生产者压测时发现性能随生产者数量增加呈超线性下降。排查翻出经典问题:_head 写在消费者线程,_tail 被生产者线程高频原子更新,它们落在同一个缓存行里时,任意一方更新都会导致整行在多个核心间无效化,互相拖慢。
解决方法是把这两个字段重新布局,让它们落到不同的缓存行上。我用 [StructLayout(LayoutKind.Explicit)] 手动指定偏移,或者每个字段前面塞 64 字节填充:
csharp复制private int _head;
private readonly long _padding1;
private readonly long _padding2;
private readonly long _padding3;
private int _tail;
虽然 C# 的字段顺序不一定按声明顺序布局,但用 StructLayout 指定偏移是有效的。这个调整在 4 生产者场景下直接带来了约 30% 的提升,属于收益最大的一次微优化。
5.3 引用类型元素的内存回收
如果你确实想在原生队列里暂存引用类型,一个变通的方案是存 IntPtr 或对象句柄,比如用 GCHandle 把对象固定在指针上,把 IntPtr 作为 T 入队。但 GCHandle 的分配和释放本身有开销,而且固定对象会阻止 GC 迁移,堆碎片风险上升。实际项目里我一般避免这种用法,宁可让上层把对象转换成扁平化的值结构,不在队列边界上传递托管引用。
5.4 一个困扰很久的内存序问题
早期版本里,消费者侧我用了一句普通读 _state[index],而不是 Volatile.Read。在 x86 上完全没问题,因为 x86 的 load 本身就带 acquire 语义。后来我把代码放到 ARM 机器上跑正确性验证,随机产生了几个数据错乱,排查了一天半,最终定位就是 _state 的读缺少 acquire 屏障,导致后续的 _buffer[index] 读取被硬件重排到了状态判断之前。
从那以后我给自己定了一条规矩:涉及跨线程共享的可变状态,读写一律走 Volatile.Read / Volatile.Write,不要在 x86 上验证通过就默认跨平台安全。 .NET 的内存模型虽然在 JIT 层做了很多屏障补偿,但严谨的做法永远是把意图写清楚,而不是依赖运行时补偿。
5.5 无锁代码的验证工具
无锁代码很难靠肉眼看正确。我平时会做三件事:
- 跑长时间多线程压力测试,挂上
dotnet-trace采集 GC 统计,确认没有异常的 gen0 分配; - 用
Interlocked计数器在入队和出队两侧分别统计总元素数,最后对账,确保不丢不重; - 如果条件允许,在 ARM 机器或仿真环境里跑正确性测试,因为 ARM 内存模型比 x86 弱,很多隐患会先暴露。
另外一个特别实用的措施是往队列元素里塞递增序号,消费者校验接收到的序号一定是严格递增的,哪怕入队顺序被多线程打乱,消费者侧的顺序也应该与 tail 分配顺序一致。这个校验能拦截大多数内存序和 CAS 逻辑错误。
6. 后续扩展与个人经验
6.1 从 MPSC 到 SPMC 的改造
有时候你手里只有一个生产者,但希望有多个消费者并行消费,比如网络分发系统希望多核分担消息处理。SPMC 场景下,消费者之间需要竞争 _head,这时 _head 也要改成 CAS 推进。改造思路是把 consumers 侧的取值逻辑变成"原子拿一个 head 序号,然后处理对应槽位",类似生产者的抢槽逻辑。但要注意,多个消费者并发消费环形缓冲区时,处理顺序不再严格确定,对依赖顺序消费的业务是不适用的。
如果你真的要一读多、多读多,建议直接评估 Channel<T> 或 ConcurrentQueue<T>。无锁 SPMC 的复杂度和 MPSC 不是一个量级,收益却因为消费者之间的竞争而大打折扣。
6.2 一个值得长期保留的底层组件
这个队列在我项目里稳定跑了两个大版本,最大的体会是:性能不是靠堆奇技淫巧,而是靠把约束想清楚。因为一开始就锁定了单消费者这个前提,整个实现才能简化到 CAS 抢槽 + volatile 发布两个核心动作。如果当初想做一个通用 MPMC,代码量和调试成本都会翻好几倍。
如果你要把它集成到自己的项目里,建议做到三条:第一,队列容量根据生产速率和消费速率的比值留足余量,避免频繁触发满队列分支;第二,在消费循环里加入 Thread.SpinWait 或 Thread.Yield 的退避策略,防止消费者空转把 CPU 烧满;第三,队列的 Dispose 逻辑一定要等待所有生产者线程退出后再调用,这个边界最好由上层调用方保证,队列本身不做等待,因为等待语义会让无锁队列的持有线程不可控。
另外,如果你用 .NET 9 或更新版本,可以关注一下 .NET 底层对 Volatile 和原子操作的进一步优化,新指令集(比如 Armv8.1 的 LSE 原子指令)会让 Interlocked.CompareExchange 的代价更低,这套队列在 ARM 服务器上也会有不错的表现。
最后分享一个实际操作里的小技巧:无论你的无锁队列写得多么自信,都记得在部署前跑一次高并发 + 随机间隔消费的混合压测,特别是在目标生产机器上跑。很多无锁问题只在特定核数、特定缓存行冲突模式下才现形,桌面级测试通过不等于服务器上安全。队列这种底层组件出问题,影响面是全局的,测试这一环永远别省。
