ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

.NET并发集合实战:从List线程不安全到ConcurrentQueue与ConcurrentDictionary选型指南

.NET并发集合实战:从List线程不安全到ConcurrentQueue与ConcurrentDictionary选型指南

排查线上问题是件很有意思的事,尤其是那种一周只出现一两次、一到关键时刻就崩的问题。前阵子我就被这样一个问题折腾了好几天:日志里时不时出现 ArgumentException,提示"目标数组的长度不够",偶尔还会跟一个 IndexOutOfRangeException 一起出现。频率不高,但每次出现都会连带一批订单状态对不上,非常难受。定位了好几轮,最后把范围缩到一段用List<T>收集并行任务结果的代码上。

原因其实很经典:多个线程同时往同一个List<T>里 Add 元素,而List<T>在扩容时会重新分配内部数组并复制元素,这个过程中数组引用被更新、索引被推进的步骤完全不是原子的。两个线程同时走到扩容逻辑,一个读到了过期的数组引用,索引直接越界。低并发下这种问题几乎不会出现,流量一上来就是概率性的崩溃,一周可能才一两次,排查体验极其糟糕。后来我把中间结果集合换成了ConcurrentQueue<T>,同样的逻辑什么都没动,问题直接消失。这也成了我系统梳理System.Collections.Concurrent命名空间的契机。

这篇文章不打算像 MSDN 那样把每个类都罗列一遍,我按自己在真实项目里踩过的坑、做过的选型取舍来讲。如果你是刚接触多线程开发的读者,可以拿它当一份比文档更"接地气"的入门指南;如果你已经在用并发集合,我相信里面提到的几个陷阱,大概率也曾经坑过你。

1. 当List遇到并发:一次线上崩溃让我重新审视"线程安全"

1.1 崩溃现场:扩容不是原子的

先说当时的代码,简化之后大概长这样:

var results = new List<int>(); Parallel.For(0, 100_000, i => { results.Add(i); }); Console.WriteLine(results.Count);

这段代码在低并发下看起来人畜无害,但实际上List<T>内部维护着一个数组和一个_size字段。每次 Add 的时候,它先看_size是否到达数组长度,到达了就先扩容:新开一个更大的数组,把旧数据 Copy 过去,再把引用指向新数组,最后才写入新元素、递增_size。

单线程下这套流程没有任何问题,但多线程同时执行 Add 时就不一样了。线程 A 检查发现需要扩容,负责扩容,但还没完成;线程 B 同时也在检查,它也看到需要扩容,自己也扩了一份。两个线程各自操作不同的数组实例,最后写入时可能互相覆盖引用,或者一个线程在旧数组上写入,而另一个线程已经把引用换成了新数组,最终数据要么丢失,要么索引撞上_size的边界。这也是为什么日志里出现的异常五花八门:ArgumentException、IndexOutOfRangeException、数据无端变少,都跟扩容的非原子性有关。

而且这种问题和环境强相关。本机调试可能跑一百次都不崩,线上服务器 CPU 核数多、线程调度更激进,概率就上来了。最坑的是它没有固定的复现路径,基本只能靠"经验直觉"猜。

1.2 加锁能解决,但吞吐量上不去

我第一反应是加锁,这也是大多数人的第一反应。用lock把 Add 包起来,问题确实立刻消失:

private readonly object _sync = new object(); var results = new List<int>(); Parallel.For(0, 100_000, i => { lock (_sync) { results.Add(i); } });

这种做法坏在并发能力上。lock是互斥锁,意味着同一时刻只有一个线程能走进 Add 的临界区,其他线程全部阻塞。对集合这种高频操作来说,锁等待的时间往往比实际 Add 的时间还长。如果你只是收集数据还好,如果这个操作处于一个每秒执行上万次的流水线上,lock会成为明显的瓶颈。

另外还有一个隐性风险:用lock保护"每个操作"并不等于"每个组合操作"是安全的。比如你要先判断集合里有没有某个元素,没有才 Add,这个"判断+添加"的组合如果不放在同一个锁里,照样会出现重复添加或者漏添加。锁粒度怎么控制,往往比加不加锁更难。

1.3 System.Collections.Concurrent 的设计思路:分工而不是一把大锁

System.Collections.Concurrent命名空间提供的那批集合类,思路跟"全局加锁"完全不同:它们不再把整个集合看成一块需要保护的共享资源,而是把内部结构拆细,让不同的线程尽量操作不同的部分。

以ConcurrentDictionary<TKey, TValue>为例,它内部把数据按哈希分到不同的桶里,每个桶有自己的锁。线程 A 往 1 号桶写,线程 B 往 2 号桶写,两者各写各的,互不阻塞。只有哈希到同一个桶的时候才需要短暂竞争一下。这样一来,Add、Remove、Update 这类高频操作在很多场景下可以真正做到并行执行,而不是排队等待。

再比如ConcurrentQueue<T>和ConcurrentStack<T>,它们在很多路径上直接用Interlocked系列的原子操作完成入队、出队、入栈、出栈,连锁都不用进。原子操作在 CPU 层面保证"读-改-写"是一个不可分割的整体,配合失败后的自旋重试,既保证了正确性,又把阻塞降到最低。

所以当你面对"多线程操作共享集合"这个问题时,第一反应不应该是"我该用哪把锁",而应该是"有没有一个专门为这个场景设计的数据结构"。这就是并发集合存在的意义:把并发安全的复杂度收敛到集合内部实现里,让调用方不用关心锁的细节。

2. ConcurrentDictionary拆解:安全背后的代价与陷阱

2.1 桶级锁和无锁快路径:为什么它比"全表锁"快

ConcurrentDictionary<TKey, TValue>是这个命名空间里我用的最多的类,没有之一。它的结构抽象来看就是"哈希表 + 每桶独立锁 + 无锁快路径"。

当你调用TryGetValue的时候,它会先根据 key 的哈希值定位到对应的桶,然后在桶的链表里找节点。这个过程在正常情况下是无锁的,完全靠 volatile 和原子读来确保看到的数据足够新。只有在写入、删除这类需要修改链表的操作时,才会去拿那把对应桶的锁。这意味着读操作几乎永远不阻塞,写操作也只会在哈希冲突到同一个桶的时候发生竞争。

我拿它替换过一个高频缓存。原先的实现是:一个普通Dictionary<TKey, TValue>加一把ReaderWriterLockSlim,读多写少,理论上已经很不错了。但实际压测发现,ReaderWriterLockSlim在高并发读模式下仍然有锁切换成本,而换成ConcurrentDictionary之后,读路径上的开销几乎只集中在哈希计算和一次内存访问上,吞吐量直接翻了一倍多。

不过桶锁设计也有代价:它占用内存比普通字典大不少,因为每个桶都要维护独立的锁对象和链表节点。如果你数据量小、并发又低,用ConcurrentDictionary反而比普通Dictionary慢,因为那一堆锁结构的初始化和维护成本不是白给的。

2.2 GetOrAdd 的工厂回调并没有你想的那么"一次"

GetOrAdd(key, valueFactory)是很多缓存库的首选 API:key 不存在就执行 factory 创建值,存在就直接返回已有值。听起来很美,但这里藏着一个经典陷阱:factory 可能会被并发执行多次。

原因是GetOrAdd的实现里,当它发现 key 不存在时,会去获取对应桶的锁,然后再次检查是否存在,不存在才调用 factory。在"第一次检查 key 是否存在"和"真正拿到桶锁"之间,可能已经有多个线程都在等待同一把锁。等它们依次拿到锁以后,各自都会再次检查,也各自都会调用一次 factory,最后只有第一个写入的结果会被保留,其他线程的 factory 返回值直接被丢弃。

如果 factory 只是new Person()这种轻量操作,无所谓。但如果 factory 内部是一次 RPC 调用、一次数据库查询,或者构建一个开销很大的对象,那重复执行就是实打实的资源浪费。我在一个项目里就见过这种情况:服务启动时多个线程同时调用某个GetOrAdd去初始化一个配置对象,结果配置中心被打了三次请求,全是重复的。

稳妥的做法是绕一层Lazy<T>:

var cache = new ConcurrentDictionary<string, Lazy<MyConfig>>(); var config = cache.GetOrAdd( "config", key => new Lazy<MyConfig>(() => LoadConfigFromRemote(key)) ).Value;

Lazy<T>保证Value属性只被真正计算一次,即使外层GetOrAdd的 factory 被调了多次,也只是创建了几个还没触发的Lazy壳子,实际重活只干了一次。内存开销略微增加,但换来的是不再有重复副作用。这个模式我非常推荐。

2.3 Count、Keys、枚举:弱一致性带来的隐藏成本

并发集合在遍历和统计方面和普通集合有本质区别。ConcurrentDictionary的枚举是弱一致的:迭代过程中其他线程做的修改,可能反映出来,也可能不反映出来,甚至可能出现"迭代到一半发现刚才的 key 已经被删了"的情况。这不会抛异常,普通Dictionary在迭代时被修改会直接抛InvalidOperationException,并发集合则选择了"尽力而为"。

这种设计换来的是无锁遍历,损失的是快照一致性。如果你需要一个"某一瞬间的完整快照",可以用ToArray()或者Keys/Values属性,它们会生成一份拷贝。但代价是 O(n) 的遍历和额外内存分配,在集合很大的时候需要留意。

Count属性大多数时候是 O(1) 的,因为它维护着一个原子计数器,但这个值在并发下只是近似值。你读到 100 的瞬间可能已经变成 101 了,用它做判断条件时要能接受这种误差。我在实际项目里就犯过错误:先判断Count == 0再去执行某个逻辑,结果正好赶上其他线程刚 Add 进去一个元素,顺带丢了一条数据。后来改成直接用IsEmpty属性和返回值判断,而不是先看Count。

IsEmpty在并发集合上的实现大多比Count == 0高效,因为Count需要扫描或者读取原子计数器,而IsEmpty通常只需要检查头节点之类的位置信息。一句话总结:在并发场景下,宁可依赖操作返回值,也不要依赖一个瞬间就会过期的统计数字。

3. 生产者与消费者的标准答案:Queue、Stack、BlockingCollection

3.1 ConcurrentQueue 的 TryDequeue 循环和 IsEmpty 判断

ConcurrentQueue<T>是典型的无锁队列,入队走尾节点,出队走头节点,两端分别用原子操作来更新,所以生产者和消费者各干各的,理论上互不阻塞。这个类在多线程日志收集、任务分发里几乎是标配。

用的时候有个习惯要改:不要先检查 Count 再 Dequeue。正确姿势是循环TryDequeue直到返回 false:

var queue = new ConcurrentQueue<int>(); // 生产者 for (int i = 0; i < 1000; i++) { queue.Enqueue(i); } // 消费者 while (queue.TryDequeue(out var item)) { Process(item); }

TryDequeue返回 false 只表示"这一刻队列是空的",不代表下一秒还是空的。所以如果你把while (queue.IsEmpty)当作退出条件,大概率会提前退出或者漏掉最后几个元素。我在做批量任务分发时踩过一次,最后一批任务总有零星几条没有被处理,排查了半天发现就是消费者线程用IsEmpty判断提前结束了。

3.2 BlockingCollection:给队列装上阻塞和边界

ConcurrentQueue<T>本身不提供阻塞能力。消费者想"队列空了就睡觉,有数据再醒",生产者想"队列满了就别再往里塞",这些逻辑得自己实现。BlockingCollection<T>就是干这个的。

它的典型用法是包装一个ConcurrentQueue<T>,然后通过Add、Take、GetConsumingEnumerable这些 API 实现阻塞消费。它内部用信号量控制边界:生产者在Add时如果容量已满,会阻塞等待;消费者在Take时如果队列为空,也会阻塞等待。这个"两端都会阻塞"的特性,天然就是生产者-消费者模型里需要的缓冲通道。

我还特别推荐使用boundedCapacity参数。它给队列设定一个最大长度,超过之后生产者会阻塞,这其实是反向压力机制:下游消费不过来了,上游就别无脑继续生产。没有这个边界,一旦消费者卡住,队列会无限膨胀,最后把内存耗光,服务直接 OOM。我见过不止一次线上事故,根因都是"无界队列 + 消费者故障",这种问题加一个boundedCapacity就能有效缓解。

3.3 一个完整的"批量任务流水线"示例

把队列、阻塞和并发集合组合起来,可以搭出一条很清晰的任务流水线。下面是我在某个项目中实际用过的简化版:

using var queue = new BlockingCollection<int>( new ConcurrentQueue<int>(), boundedCapacity: 2000 ); // 消费者线程 var consumer = Task.Run(() => { foreach (var item in queue.GetConsumingEnumerable()) { Process(item); } }); // 生产者 foreach (var order in FetchOrders()) { queue.Add(order.Id); } // 通知消费端没有更多数据了 queue.CompleteAdding(); // 等消费者把剩余数据处理完 consumer.Wait();

这里有几个细节值得说一下。GetConsumingEnumerable()返回的迭代器会在CompleteAdding被调用后,先把队列里剩余的元素全部消费完,然后才结束迭代。所以生产者结束前调用CompleteAdding()通知消费者,消费者就能优雅退出,不会丢数据也不会死循环。

另外BlockingCollection<T>的默认底层集合其实是ConcurrentQueue<T>,你不传第一个参数它也会用。当你需要 LIFO 行为的时候,可以传ConcurrentStack<T>作为底层集合;需要无序去重的场景还可以用ConcurrentBag<T>。

3.4 CompleteAdding 之后:不要尝试 Add

CompleteAdding()这个调用本质上是个状态标记。调用之后,集合内部会把"生产者已经全部离开"这个信息广播给消费者,但这里有一个比较隐蔽的问题:如果CompleteAdding()执行之后还有漏网的生产者线程在调用Add,会抛出InvalidOperationException。

所以在一个由多个生产者线程组成的系统里,你要保证CompleteAdding()是在最后一个Add调用之后才执行的,而且最好由统一的协调角色来完成,而不是让任意一个生产者线程用完队列就顺手调用。我在一个并行爬虫项目里就犯过这个错误:多个生产者各自抓取数据后往队列里塞,其中一个线程结束得早,调用了CompleteAdding(),另外几个线程还没塞完,直接异常。后面的修复方式是把队列的"完成信号"跟"最后一个生产者的结束"绑定在一起,而不是跟"任何一个生产者的结束"绑定。

4. ConcurrentBag的性能真相:本地缓存与工作窃取

4.1 为什么ConcurrentBag"每个线程快,跨线程慢"

ConcurrentBag<T>是并发集合里最容易用错的一个,因为它的行为违反直觉。它的内部实现是"线程本地缓存 + 工作窃取":每个线程往 bag 里 Add 时,优先放进自己线程私有的一个列表;Take 时也优先从自己的私有列表取。只有当本线程的私有列表空了,它才回去"偷"其他线程的私有列表。

这套设计的直接后果是:同一个线程 Add 再 Take,非常快——这完全是一条无锁的本地路径;但如果一个生产者线程 Add,另一个消费者线程 Take,消费者在自己本地列表空空如也的情况下,就要去别人家里偷,这涉及跨线程锁和链表操作,性能会明显下降,甚至比加锁的普通集合还慢。

我在性能测试里对比过两种场景。场景 A:数据在同一个线程内 Add 和 Take,ConcurrentBag比ConcurrentQueue快一倍左右;场景 B:一个生产者线程往 bag 里 Add,多个消费者线程 Take,ConcurrentBag的性能就掉到了跟普通集合加锁差不多的水平,明显不如ConcurrentQueue。

4.2 它真正适合的场景与一个反例

ConcurrentBag<T>正确的打开方式是"线程局部数据收集 + 最终汇总"。比如Parallel.For里每个线程独立处理一部分任务,处理过程把中间结果放进自己的 bag,最后所有线程跑完了再统一遍历 bag。因为每个线程基本只碰自己的那段私有列表,跨线程偷取几乎不发生,性能很好。

反例就是无脑地把它当成线程安全的List<T>来用:所有线程都往同一个 bag 里 Add,另一个线程不断 Take。这种场景下,你可以想象成大家都在自己家堆放货物,但需求方不带钥匙,只能挨家挨户敲门去借,效率能高才怪。

ConcurrentBag<T>的迭代也很有特点:枚举到的元素并不会严格对应某个线程的完整列表,它遍历的是所有线程的本地列表,顺序是随机的,同一个线程内部的顺序倒是有一定规律。如果你需要 LIFO 或者 FIFO 的确定性顺序,选ConcurrentStack或者ConcurrentQueue才对。

5. Interlocked与内存模型:并发集合的底层基石

5.1 原子操作到底做了什么:CAS与自旋

标题里提到了Interlocked,这个类值得展开说。Interlocked.Increment(ref long)、Interlocked.CompareExchange(ref long, value, comparand)这类方法,对应的是一连串底层 CPU 指令。以CompareExchange为例,它做的事情是"如果目标位置的值等于 comparand,就把 value 写进去;不管写没写,都把目标位置原来的值返回出来"。关键点在于,这个"读-比较-改写"的整个过程是不可以被打断的,其他线程在CPU指令级别上无法插队。

并发集合里的很多无锁算法就建立在CompareExchange之上,最常见的套路是CAS + 自旋:

// 伪代码示意 while (true) { var oldValue = target; var newValue = Compute(oldValue); if (Interlocked.CompareExchange(ref target, newValue, oldValue) == oldValue) { break; } }

线程先读旧值,根据旧值算出新值,然后尝试把新值写回去。如果写完以后发现目标位置的值不是自己之前读到的旧值,说明别的线程抢先改了,那就重新再来一轮。这个过程虽然会自旋空转,但不会让线程进入阻塞状态,对于锁竞争时间很短的场景,自旋比挂起线程和唤醒线程的开销小得多。ConcurrentQueue和ConcurrentDictionary里大量用到了这种模式。

5.2 内存屏障和伪共享:读源码时你会遇到的高频概念

理解Interlocked之后再看并发集合源码,还会碰到"内存屏障"和"伪共享"这两个概念。

CPU 为了性能会重排指令,也会把数据先放在缓存里不急着写回内存。多线程场景下,一个线程修改了数据,另一个线程可能看到的还是旧值。内存屏障就是一道指令,强制保证它前后的读写操作以正确的顺序被观察到。Interlocked方法自带全屏障语义,即在执行时同步保证之前和之后的读写不会被 CPU 或编译器随意重排,这也是它被广泛用于无锁编程的原因。

伪共享就更微妙了。CPU 缓存是按"缓存行"为单位读取的,一个缓存行通常是 64 字节。如果两个高频更新的变量恰好落在同一个缓存行里,哪怕它们逻辑上毫无关系,只要一个变量被修改,整个缓存行都会标记失效,另一个变量所在的 CPU 核心就得重新去内存加载。这就是伪共享:看似独立的并发写,却因为共享同一个缓存行而互相拖慢。并发集合在实现时会刻意把频繁更新的字段打散到不同的缓存行上,或者用填充字节让它们尽量不凑在一起。你不需要自己经常实现这种优化,但读源码时看到 64 字节对齐之类的操作会更容易理解。

5.3 手写一个锁远比想象中难

看了一些无锁代码之后,不少人会想:那我是不是可以手写无锁集合?我的建议是,除非你就是想研究底层原理,否则别在生产环境自己造这个轮子。

无锁编程的正确性依赖极其严格的内存序理解,一个看似正确的 CAS 循环可能因为漏禁止了一次重排,在高并发下偶发数据不一致。我见过有人用"简单自旋 + Thread.Sleep(1)"自己实现阻塞队列,结果在 CPU 核心多的时候因为 Sleep 导致吞吐量暴跌,在核心少的时候又因为自旋把 CPU 占满。这还是在比较简单的队列上,更别提字典、栈、bag 这些结构。

System.Collections.Concurrent里的实现是经过大量测试和调优的,它不光要考虑功能正确,还考虑缓存友好、锁竞争最小化、扩容策略等大量工程细节。自己写一个能用,跟写好一个能扛住生产环境的高并发,是两回事。

6. 并发集合选型指南:不同场景该用什么

6.1 一张表看懂六个集合的定位

我把常用的六个集合按自己的理解整理成了一张表,方便在不同需求下快速定位:

集合类型底层机制适用场景避开场景
ConcurrentDictionary<TKey,TValue>桶级锁 + CAS高并发键值缓存、状态聚合、去重读多写少且对快照一致性要求高
ConcurrentQueue<T>无锁 + CAS生产者-消费者、任务队列需要优先级或确定顺序时
ConcurrentStack<T>无锁 + CASLIFO 栈、最近优先处理大多数业务场景很少用
ConcurrentBag<T>ThreadLocal + 工作窃取线程本地中间结果聚合跨线程频繁转移数据
BlockingCollection<T>SemaphoreSlim + 同步集合阻塞缓冲、有界队列不需要阻塞逻辑的简单队列
IProducerConsumerCollection<T>接口,抽象上述队列/栈/bag需要自定义阻塞集合时只调用一次的场景

实际操作中还有一个不容易注意到的点:ConcurrentStack<T>的TryPeek在某些实现下需要小心 —— 它内部会遍历链表的头节点,但如果头节点正被其他线程修改,可能会触发内部的回退逻辑。多数情况下它的表现是 O(1) 的,但极端并发下并不保证。我不是说它有问题,只是提醒大家不要去依赖"绝对 O(1)"这样的说法。

6.2 什么时候不该用Concurrent集合

Concurrent集合不是银弹,有几个场景我反而会劝你换回普通集合加锁,或者直接选别的数据结构。

低并发 + 小数据量是第一个场景。几个线程偶尔碰一次共享列表,数据量只有几十条,用ConcurrentDictionary比普通Dictionary加一次锁要慢得多,原因前面说过:桶锁结构、volatile 读、额外节点内存,这些都是成本。我自己的经验是,当并发线程数低于 4 或者操作频率低于每秒几千次时,lock的竞争其实可以忽略不计,直接上最简单的方式反而更好维护。

读多写少、需要快照一致性是第二个场景。如果一个集合基本不写,但会被频繁读取,而且要求读到的是一个完整一致的瞬间快照,ConcurrentDictionary的弱一致性会让每次枚举都可能看到不完整的视图。这时候用ImmutableDictionary或者普通字典加读写锁,可能更合适。

数据量极大且内存紧张是第三个场景。Concurrent系列集合的内存占用普遍比普通集合高,因为它们要存储锁对象、链表节点、线程本地缓存等额外结构。一个上千万条数据的字典,用并发版本可能比普通版本多吃几百兆内存。在有内存预算约束的环境里,这个差异必须纳入考虑。

6.3 我个人在实际项目里常用的组合套路

最后分享几个组合用法,谈不上标准答案,但都是我试过之后沉淀下来比较稳的。

第一套是高频缓存:ConcurrentDictionary<TKey, Lazy<TValue>>作为核心,配合GetOrAdd,适用于"值创建开销大、访问频率高、允许弱一致性读取"的场景。前面已经写过示例,这套组合我几乎在每个服务里都会用到,它既有并发安全的写入,又能避免工厂方法重复执行。

第二套是多生产多消费的任务通道:BlockingCollection<T>(new ConcurrentQueue<T>(), boundedCapacity)用一个有界队列做缓冲,GetConsumingEnumerable()做消费端循环,CompleteAdding()做优雅收尾。这套组合的核心价值是反向压力和优雅退出,我强烈建议在生产环境给所有任务队列加上容量上限,宁可在上游多等一会儿,也别让队列在内存里无限堆数据。

第三套是并行任务的局部汇总:如果你在用Parallel.For处理一批任务,希望把每个任务产生的中间结果收集起来,最后再统一处理,用ConcurrentBag<T>配合Parallel.For就很合适。每个线程往自己的本地列表里写,跨线程偷取被降到最低,最后遍历汇总即可。这个用法贴合了ConcurrentBag的设计初衷,也是它性能表现最好的场景。

最后分享一些我的实际体会

这几套组合用下来最大的感受是:并发集合的价值不在于让你完全不用思考并发,而在于把"并发操作集合"的正确性成本从调用方转移到了集合内部。你在调用ConcurrentDictionary.GetOrAdd的时候如果不去想"万一 factory 被并发调用了怎么办",它照样会坑你;你如果不了解ConcurrentBag的工作窃取机制,用错了场景,它甚至比普通集合并上锁还慢。工具提供的只是基础保障,真正的线程安全性,仍然依赖于你对工具适用边界的理解。

我在项目里给团队定过一条简单规则:凡是共享集合,先问三个问题——并发有多高?读写比例如何?对快照一致性有没有要求?想清楚这三个问题,再决定是用普通集合加锁、用并发集合,还是用不可变数据结构。把选择建立在场景而不是惯性上,比背一堆 API 名称有用得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进