1. 多线程场景下,为什么普通集合会“翻车”
单线程写代码时,List<T>、Dictionary<TKey,TValue>、Queue<T>这些集合用起来顺手得很,增删改查一把梭。可一旦把同样的代码丢进多线程环境,问题就来了:数据莫名其妙丢失、遍历时抛InvalidOperationException、程序偶发死循环、CPU 占用飙升却查不出原因。这些坑我几乎每一个都踩过,而且往往在测试环境跑得好好的,一上生产就间歇性抽风,排查起来极其折磨人。
线程安全的集合,说白了就是多个线程同时读写同一个集合时,集合内部能保证数据不损坏、状态不混乱。它解决的核心问题是并发访问下的数据一致性。这篇文章适合所有写 C# 多线程、异步、后台任务、定时作业的开发者,不管你是刚接触Task的新手,还是已经用过lock但总觉得别扭的老手,都能从中找到可以直接抄作业的方案。
我先说一个很多人忽略的事实:线程安全集合并不是“加了锁的普通集合”这么简单。它们的内部实现针对并发场景做了大量优化,比如分段锁、无锁算法、快照遍历等。理解这些设计,才能选对集合、用对方法,而不是盲目地给所有操作套一层lock,把并发性能活活拖死。
2. 先搞清楚:线程安全到底在防什么
2.1 三种典型的并发事故现场
在讲具体集合之前,得先明白普通集合在并发下会出什么事。我把常见问题归成三类,理解了这三类,后面选型就有依据了。
第一类是数据竞争导致的状态损坏。比如两个线程同时对List<int>调用Add,内部维护的_size字段和底层数组可能被同时修改,结果就是元素数量对不上,或者某个位置的数据被覆盖。这种问题最阴险,因为它不一定抛异常,只是悄悄丢数据。
第二类是遍历期间的修改异常。List<T>和Dictionary都有一个版本号机制,遍历时如果检测到集合被修改,就抛InvalidOperationException: Collection was modified。单线程下这是保护机制,多线程下就变成了随机炸弹——你永远不知道哪个线程会在遍历时被另一个线程的写入打断。
第三类是复合操作的非原子性。这是最容易被忽视的。比如“先判断 key 是否存在,不存在就添加”这种ContainsKey+Add的组合,即使每个方法单独看都是线程安全的,组合起来依然有竞态窗口:两个线程可能同时判断为“不存在”,然后都去添加,后一个要么覆盖前一个,要么抛异常。
注意:很多人以为用了线程安全集合就万事大吉,其实复合操作依然需要额外保护。这是新手最容易翻车的地方。
2.2 线程安全集合的两种实现思路
C# 里的线程安全集合,底层大致分两条路线。
一条是锁分离/分段锁。典型代表是ConcurrentDictionary,它把数据分成多个桶,不同桶用不同的锁,这样多个线程操作不同区域时几乎不互相阻塞。这种设计在读写混合场景下性能很好。
另一条是无锁或轻量同步。比如ConcurrentQueue和ConcurrentStack,内部大量使用Interlocked原子操作和自旋,避免了重量级的内核锁。它们在纯入队出队场景下吞吐量非常高。
理解这两条路线,你就能明白为什么ConcurrentDictionary适合缓存,而ConcurrentQueue适合生产者消费者队列——它们的优化方向本来就不一样。
2.3 命名空间与集合家族全景
这些集合都在System.Collections.Concurrent命名空间下,主要包括:
| 集合类型 | 底层结构 | 典型场景 |
|---|---|---|
ConcurrentDictionary<TKey,TValue> | 分段哈希表 | 缓存、共享状态字典 |
ConcurrentQueue<T> | 无锁链表/环形缓冲 | 生产者消费者、任务队列 |
ConcurrentStack<T> | 无锁链表 | 后进先出场景、撤销栈 |
ConcurrentBag<T> | 线程本地存储+全局池 | 无序并行收集结果 |
BlockingCollection<T> | 包装其他集合 | 有界队列、阻塞式消费 |
另外还有ImmutableArray、ImmutableList这类不可变集合,它们走的是完全不同的思路——每次修改返回新集合,天然线程安全,但内存开销大,适合读多写极少且需要快照语义的场景。
3. ConcurrentDictionary:缓存场景的主力选手
3.1 为什么它比 lock + Dictionary 强
我早期写缓存,习惯用Dictionary加一把大锁,所有读写都排队。数据量小的时候没感觉,一旦并发上来,锁竞争直接把吞吐量压到地板。后来换成ConcurrentDictionary,同样的压测场景,QPS 提升了好几倍。
原因就在于分段锁。ConcurrentDictionary内部维护一个桶数组,每个桶有自己的锁。线程 A 操作桶 1、线程 B 操作桶 5,两者完全不冲突,可以真正并行。只有当两个线程恰好命中同一个桶时才会短暂等待。这种设计把锁的粒度从“整个字典”缩小到“单个桶”,并发度大幅提升。
3.2 GetOrAdd 的正确打开方式
GetOrAdd是ConcurrentDictionary最常用的方法,但它的行为有个大坑:工厂委托可能会被执行多次。看下面这段代码:
var cache = new ConcurrentDictionary<string, ExpensiveObject>(); var obj = cache.GetOrAdd("key", k => new ExpensiveObject(k));如果两个线程同时发现 key 不存在,它们可能都会执行new ExpensiveObject(k),然后其中一个的结果被丢弃。对于创建成本高、有副作用的对象,这是灾难。
正确做法是用Lazy<T>包装:
var cache = new ConcurrentDictionary<string, Lazy<ExpensiveObject>>(); var obj = cache.GetOrAdd("key", k => new Lazy<ExpensiveObject>(() => new ExpensiveObject(k), LazyThreadSafetyMode.ExecutionAndPublication)).Value;这样即使工厂被执行多次,真正创建对象的Lazy只会有一个生效,其余线程拿到的是同一个实例。这个技巧我在实际项目里用了无数次,强烈建议记住。
3.3 AddOrUpdate 与复合操作的原子性
AddOrUpdate解决了“读取-修改-写入”的原子性问题。比如统计计数:
var counters = new ConcurrentDictionary<string, int>(); counters.AddOrUpdate("hits", 1, (key, old) => old + 1);注意,更新委托同样可能被多次调用,所以委托内部不要有副作用,只做纯计算。如果更新逻辑复杂,建议改用TryUpdate配合循环重试,逻辑更可控。
实操心得:
ConcurrentDictionary的Count属性在并发写入时是一个近似值,不要用它做精确判断。需要精确计数时,用Interlocked单独维护一个计数器。
3.4 遍历时的快照语义
ConcurrentDictionary的遍历是线程安全的,不会抛修改异常,因为它返回的是一个“时刻快照”式的枚举。但要注意,遍历过程中其他线程的修改不一定能反映出来。如果你的业务要求遍历时看到最新数据,那得换思路,比如加版本号或者用读写锁。
4. ConcurrentQueue 与 ConcurrentStack:队列场景的利器
4.1 生产者消费者模型的天然搭档
ConcurrentQueue<T>是我做后台任务处理时的首选。多个生产者线程Enqueue,多个消费者线程TryDequeue,全程无锁,吞吐量非常可观。它的典型用法:
var queue = new ConcurrentQueue<WorkItem>(); // 生产者 queue.Enqueue(new WorkItem()); // 消费者 while (true) { if (queue.TryDequeue(out var item)) { Process(item); } else { Thread.Sleep(10); // 避免空转烧CPU } }这里有个细节:TryDequeue返回bool,必须判断返回值再用item,否则可能拿到默认值。我见过有人直接var item = queue.Dequeue(),结果编译不过——ConcurrentQueue根本没有Dequeue方法,只有TryDequeue。这个设计是故意的,强制你处理“队列为空”的情况。
4.2 空转问题的三种解法
上面代码里的Thread.Sleep(10)是一种简单粗暴的解法,但不够优雅。更好的方案有三种:
第一种是用BlockingCollection<T>包装,它的Take方法在队列为空时会阻塞,不消耗 CPU。第二种是用SemaphoreSlim配合信号量,生产者在入队后释放信号,消费者等待信号。第三种是Channel<T>,这是较新版本提供的异步队列,配合async/await用起来非常舒服。
我个人在同步场景用BlockingCollection,异步场景用Channel,基本覆盖了所有需求。
4.3 ConcurrentStack 的适用边界
ConcurrentStack<T>是后进先出,适合撤销操作、深度优先遍历这类场景。它的TryPop和TryPeek用法和队列类似。但要注意,栈的并发性能在极端情况下不如队列,因为所有操作都集中在栈顶这一个热点上,竞争更激烈。如果发现栈成为瓶颈,可以考虑用多个栈分片。
4.4 有界队列与背压控制
无界队列有个致命问题:生产者速度远大于消费者时,内存会被撑爆。BlockingCollection<T>支持指定容量上限:
var bounded = new BlockingCollection<WorkItem>(boundedCapacity: 1000);当队列满时,Add会阻塞生产者,形成天然的背压。这个特性在流量削峰场景下非常有用,我做过一个日志采集系统,就是靠有界队列防止内存雪崩的。
5. ConcurrentBag:被低估的并行收集工具
5.1 它到底适合什么场景
ConcurrentBag<T>的设计目标很明确:同一个线程既添加又取出时性能最好。它内部为每个线程维护一个本地列表,添加时优先放本地,取出时也优先从本地拿。这样大部分操作根本不涉及跨线程同步,速度极快。
它适合的场景是并行计算中收集结果。比如Parallel.For里每个迭代产生一个结果,最后汇总:
var bag = new ConcurrentBag<Result>(); Parallel.For(0, 1000, i => { bag.Add(Compute(i)); }); var all = bag.ToArray();这种“各线程只管往自己本地塞,最后统一取”的模式,正是ConcurrentBag的主场。
5.2 为什么它不适合做队列
很多人看ConcurrentBag也能Add和TryTake,就想拿它当队列用。这是错的。因为它的取出顺序不确定,可能拿到本线程最后添加的,也可能拿到别的线程的。如果你需要严格的先进先出,老老实实用ConcurrentQueue。
注意:
ConcurrentBag的TryTake在本地为空时会去“偷”其他线程的元素,这个偷取过程有竞争开销。所以它不适合高频的生产者消费者模型。
5.3 与 PLINQ 的配合
ConcurrentBag和 PLINQ 搭配也很顺手。PLINQ 内部在某些聚合操作中就会用到类似的线程本地缓冲思路。理解ConcurrentBag的本地化设计,其实也就理解了并行计算里“减少共享”这个核心原则。
6. 不可变集合:另一条线程安全的路
6.1 不可变集合的核心思想
System.Collections.Immutable命名空间下的集合,走的是完全不同的路线:一旦创建就永不改变,任何修改操作都返回一个新集合。因为旧集合永远不会被修改,多个线程同时读它天然安全,连锁都不需要。
这听起来很浪费,但它的内部用了结构共享技术。比如往一个包含 1000 个元素的不可变列表头部加一个元素,并不是复制全部 1000 个,而是新建一个节点指向原来的数据。内存开销远没有想象中大。
6.2 什么时候该用它
不可变集合适合读多写极少的场景,比如配置数据、常量表、事件订阅列表。它的读性能极好,因为无锁。但写操作因为要创建新对象,成本比可变集合高。
我做过一个配置中心,配置每隔几分钟才更新一次,但被成千上万次读取。用ImmutableDictionary替换普通字典后,读路径彻底去掉了锁,性能提升明显,代码也更简洁——再也不用担心遍历时被修改了。
6.3 与可变线程安全集合的取舍
这里给一个简单的决策表:
| 场景特征 | 推荐方案 |
|---|---|
| 读写都频繁,键值缓存 | ConcurrentDictionary |
| 生产者消费者队列 | ConcurrentQueue/Channel |
| 并行收集结果 | ConcurrentBag |
| 读极多写极少 | ImmutableDictionary等 |
| 需要快照语义 | 不可变集合 |
| 需要阻塞和背压 | BlockingCollection |
选型没有银弹,关键是看清你的读写比例和一致性要求。
7. 实操中那些文档不会告诉你的坑
7.1 复合操作依然要加锁
这是重复强调也不为过的点。ConcurrentDictionary的单个方法是原子的,但“检查再操作”不是。比如实现一个“最多允许 100 个元素”的字典:
if (dict.Count < 100) // 这里和下一行之间有竞态窗口 { dict.TryAdd(key, value); }正确做法是用Interlocked维护计数,或者干脆用信号量控制准入。我见过生产事故就是因为这个竞态窗口,导致实际元素数超过上限,把下游服务打挂。
7.2 枚举器的快照不是实时的
所有并发集合的枚举都基于某个时刻的快照。如果你在遍历时依赖“看到最新数据”,逻辑就会出错。比如用ConcurrentDictionary做在线用户列表,遍历时统计人数,得到的可能不是精确值。需要精确值时,得用额外的同步机制。
7.3 内存可见性与 volatile
虽然并发集合内部处理了内存屏障,但你自己定义的共享字段不一定。如果一个线程写普通字段、另一个线程读,没有同步措施的话,读线程可能永远看不到新值。这时候要么用volatile,要么用Interlocked,要么把字段放进并发集合里。这个坑在无锁编程里特别常见。
7.4 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 遍历抛修改异常 | 用了普通集合 | 换并发集合或加锁 |
| 数据莫名丢失 | 复合操作竞态 | 用原子方法或加锁 |
| CPU 空转高 | 消费者忙等待 | 用阻塞队列或信号量 |
| 内存持续增长 | 无界队列堆积 | 改用有界队列加背压 |
| 缓存对象重复创建 | GetOrAdd 工厂多次执行 | 用 Lazy 包装 |
| 计数不准 | Count 是近似值 | 单独维护计数器 |
7.5 性能测试不能省
选集合之前,最好用真实数据量压测一下。我习惯用简单的多线程循环跑个几十万次操作,对比不同方案的耗时。很多时候理论上的“更优”在实际数据分布下未必成立。比如元素极少时,lock + Dictionary可能比ConcurrentDictionary还快,因为后者有额外的分段管理开销。
8. 一个完整的实战:并发日志缓冲器
8.1 需求拆解
假设要做一个日志缓冲器:多个业务线程往里写日志,一个后台线程定期批量落盘。要求写入不阻塞业务线程、内存有上限、落盘时不能丢日志。
这个需求拆开看:写入要快且线程安全,所以用ConcurrentQueue;要有上限防止内存爆,所以用BlockingCollection包装;后台批量消费,用TryTake配合超时。
8.2 核心实现
public class LogBuffer : IDisposable { private readonly BlockingCollection<string> _buffer; private readonly Task _flushTask; private readonly CancellationTokenSource _cts = new(); public LogBuffer(int capacity = 10000) { _buffer = new BlockingCollection<string>(capacity); _flushTask = Task.Run(FlushLoop); } public void Write(string log) { // 队列满时这里会阻塞,形成背压 _buffer.Add(log); } private void FlushLoop() { var batch = new List<string>(500); while (!_cts.IsCancellationRequested) { batch.Clear(); // 阻塞等待第一条,避免空转 if (_buffer.TryTake(out var first, 1000, _cts.Token)) { batch.Add(first); // 尽量多取,凑批 while (batch.Count < 500 && _buffer.TryTake(out var more)) { batch.Add(more); } Persist(batch); } } } private void Persist(List<string> batch) { // 实际落盘逻辑 } public void Dispose() { _cts.Cancel(); _buffer.CompleteAdding(); _flushTask.Wait(); _buffer.Dispose(); _cts.Dispose(); } }8.3 关键设计说明
TryTake带超时是关键。如果只用无超时的Take,关闭时可能永远阻塞。带 1 秒超时后,循环能定期检查取消标志,实现优雅退出。
批量凑数也是性能要点。逐条落盘的 IO 开销远大于批量落盘,所以先阻塞拿一条,再非阻塞尽量多拿,凑够一批再写。这个模式在日志、消息、数据采集场景里通用。
CompleteAdding配合IsCompleted可以让消费者在生产者结束后自然退出,这是BlockingCollection的标准关闭流程,比直接Cancel更干净。
8.4 压测与调优
我实测下来,容量设 10000、批量 500 是个不错的平衡点。容量太小会导致业务线程频繁阻塞,太大则内存占用高且关闭时积压多。批量太小 IO 次数多,太大则延迟高。这些参数没有标准答案,得根据你的日志速率和落盘速度调。
实操心得:
BlockingCollection的Add在队列满时阻塞,如果你的业务线程不能接受阻塞,可以用TryAdd带超时,超时后降级处理(比如丢弃或写本地文件),避免拖垮主流程。
9. 异步时代的替代方案:Channel
9.1 为什么需要 Channel
BlockingCollection是同步阻塞的,在async/await大行其道的今天,阻塞线程是一种浪费。System.Threading.Channels提供了异步版本的队列,WriteAsync和ReadAsync返回Task,不阻塞线程。
var channel = Channel.CreateBounded<WorkItem>(1000); // 生产者 await channel.Writer.WriteAsync(item); // 消费者 await foreach (var item in channel.Reader.ReadAllAsync()) { Process(item); }9.2 有界与无界的取舍
CreateBounded有容量上限,写满时WriteAsync会异步等待,形成背压。CreateUnbounded无上限,写入永不等待,但要小心内存。我一般默认用有界,容量根据下游处理能力估算。
9.3 与并发集合的关系
Channel内部其实也用了并发队列的思想,但它是为异步场景专门优化的。如果你在写异步服务,优先考虑Channel;如果是同步的传统多线程代码,ConcurrentQueue和BlockingCollection更合适。两者不是替代关系,而是不同场景的工具。
10. 我踩过的几个真实坑与排查思路
10.1 一个诡异的死锁
有次用BlockingCollection做任务队列,消费者处理任务时又往同一个队列里加子任务,结果队列满了,消费者阻塞在Add上,而它自己又占着消费位置,形成死锁。教训是:消费者内部不要再往同一个有界队列写,要么用无界,要么另开队列,要么把子任务交给别的线程池。
10.2 遍历时的性能陷阱
ConcurrentDictionary的遍历会创建快照,数据量大时这个快照本身开销不小。我有个服务每秒遍历一次十万级的字典做统计,CPU 一直偏高。后来改成增量统计,写入时顺便更新计数,遍历频率降到每分钟一次,CPU 立刻降下来。能用增量维护的,就别全量遍历。
10.3 排查并发问题的通用套路
并发 bug 难复现,我的经验是:第一,加详细日志,记录线程 ID 和操作时间戳;第二,用压力测试放大问题,把偶发变成必现;第三,怀疑共享状态时,先假设所有复合操作都有问题,逐个审查。很多时候问题不在集合本身,而在你对集合的使用方式。
10.4 关于锁的粒度
如果非要用锁,记住一个原则:锁的范围越小越好,锁内不要做 IO 和耗时操作。我见过有人在锁里查数据库,结果整个服务卡死。并发集合的价值之一就是帮你把锁的粒度降到最低,能用它就别自己造锁。
11. 选型决策与落地建议
把前面这些串起来,给一套我实际用的决策流程。先问自己三个问题:读写比例如何?需不需要顺序保证?能不能接受阻塞?
读写都频繁且是键值结构,选ConcurrentDictionary,记得用Lazy包装工厂。需要先进先出且同步场景,选ConcurrentQueue或BlockingCollection。异步场景选Channel。并行收集无序结果选ConcurrentBag。读极多写极少选不可变集合。
落地时还有几条硬建议:所有复合操作都要重新审视原子性;有界队列一定要设容量;消费者循环一定要有退出机制;上线前必须压测。这些看起来是常识,但真正做全的项目不多,而事故往往就出在这些被省略的步骤上。
最后分享一个小技巧:如果你不确定某个并发集合的行为,写个几十行的多线程小 demo 跑一下,比翻半天文档快得多。并发编程这东西,纸上得来终觉浅,亲手跑一遍,那些竞态、阻塞、快照的语义立刻就清楚了。