1. 从一次诡异的“集合被修改”异常说起:为什么需要 Ecng.Collections
我在维护一个实时行情分发服务时遇到过这样一个问题:某个价格订阅模块在深夜高频推送时,会周期性抛出InvalidOperationException: Collection was modified; enumeration operation may not execute.。这类异常通常不会导致进程崩溃,但会让一批订阅线程死循环重试,日志里瞬间刷出几百条错误,CPU 占用也莫名其妙飙升。
定位到最后,问题根源其实很简单:我在一个List<PriceTick>上边读边写,写入线程负责把新行情推到尾部,读取线程负责遍历最新价格去触发用户回调。原生List<T>根本不保证并发安全,多线程一碰撞就必然出现读取时集合被修改的问题。当时我把代码改成双重锁之后虽然不再抛异常了,但锁竞争严重拖慢了行情入库的速度,整个事件循环的吞吐量掉了将近三成。
后来一个同事给我推荐了Ecng.Collections这套集合类库,问题才算真正解决。它不是某个高深莫测的并发框架,而是一组兼顾线程安全、缓存读取、有序性和弱引用的通用集合工具,全部封装在Ecng.Collections命名空间下。这套类库最早来自一个量化交易基础框架的底层支持库,因为交易场景动不动就是上千路行情同时涌入,普通集合类在这种压力下根本扛不住,作者才手写了一批高性能集合。现在这些集合完全可以拿来放进任意 C# 后端项目里用,不需要引入庞大的第三方依赖,也没有复杂的配置,就是一个类库直接引用。
这篇文章适合谁看?如果你正在用 C# 做高并发服务、实时数据处理、消息队列消费,或者你只是对Dictionary、List、Queue这些基础集合的线程安全边界心里没底,那么把Ecng.Collections吃透会非常划算。下面我会按照集合的用途分类,逐个拆解它们的实现思路、适用场景和注意事项,最后给出实战选型建议。
2. 线程安全集合三剑客:SynchronizedList、SynchronizedSet、SynchronizedDictionary 的锁机制拆解
先说这套类库最核心的家族:SynchronizedList<T>、SynchronizedSet<T>和SynchronizedDictionary<TKey, TValue>。它们就是传统List<T>、HashSet<T>、Dictionary<TKey, TValue>的线程安全版本,但实现细节和 .NET 自带的ConcurrentDictionary之类的并发集合完全不同。
2.1 继承体系与设计理念
这三者的继承关系大致是:SynchronizedSet<T>继承自一个可复用的基类,内部维护一个普通的HashSet<T>;SynchronizedList<T>同样继承自一个列表基类,内部维护一个普通的List<T>;SynchronizedDictionary<TKey, TValue>则维护一个普通字典,并额外支持通过键访问值的this[key]索引器。
它们的核心思路非常直接:把所有公共操作(Add、Remove、Contains、Count、索引器取值等)统一用一个锁串行化。任何线程想操作集合,先拿锁,操作完释放锁。也就是说,同一时刻只有一个线程能真正碰内部的存储结构。
代码形态上大概是这么个意思:
public class SynchronizedDictionary<TKey, TValue> { private readonly object _syncRoot = new object(); private readonly Dictionary<TKey, TValue> _inner = new Dictionary<TKey, TValue>(); public void Add(TKey key, TValue value) { lock (_syncRoot) { _inner.Add(key, value); } } public bool TryGetValue(TKey key, out TValue value) { lock (_syncRoot) { return _inner.TryGetValue(key, out value); } } }别看这份代码简单,它解决了一个非常重要的问题:读操作也要加锁。很多人写多线程集合时会误以为只有写入才需要锁,读取不用。但在 .NET 的普通泛型集合里,读和写并发的时候,内部结构(如字典的桶数组、链表的指针)可能正处于临时不一致的状态,这时另一个线程来读,轻则读到脏数据,重则直接抛出访问违例异常。Ecng.Collections的设计理念是“一致性优先,性能次之”,该锁就锁,宁可慢一点也不能让状态失控。
2.2 锁的选择:lock 与 ReaderWriterLockSlim 的取舍
写到这里你可能会问:为什么不用ReaderWriterLockSlim做多读单写?那样读多写少的场景性能不是更好吗?
我实测过这两个方案的差异。ReaderWriterLockSlim适合“读操作耗时较长、读多写少”的场景,比如缓存系统里几百微秒的读取逻辑。但集合操作本身是纳秒级别的,锁的切换成本反而会吃掉收益,而且ReaderWriterLockSlim的退出还要回收LockRecursion相关状态,开销比一把朴素的lock高不少。泛型集合的每次Add、TryGetValue都极其短暂,用lock反而最划算,因为无竞争时lock的开销已经优化到极低。
另外,lock还有一个好处:可重入。SynchronizedSet<T>里的Add和AddRange如果相互调用,用lock不会出现死锁;如果换成ReaderWriterLockSlim并且没有设置LockRecursionPolicy.SupportsRecursion,就会在重入时抛异常。所以从工程稳健角度,普通锁是更省心的选择。
2.3 枚举时“集合已修改”异常的规避方案
SynchronizedList<T>和SynchronizedSet<T>最难用的点在于枚举。你写一个foreach去遍历它,如果在遍历期间另一个线程往里面加了元素,同样会抛“集合已修改”的异常。原因在于底层的List<T>和HashSet<T>都带版本号校验,迭代器检测到版本号变化就会强制失败。lock只保证了单次操作是原子的,并没法保证整个foreach循环期间别人不来写。
最稳妥的做法是把遍历过程放到锁里面。Ecng.Collections为此提供了一个很实用的设计:可以直接通过SyncRoot属性拿到内部锁对象,然后手动lock包裹整个遍历:
var list = new SynchronizedList<string>(); lock (list.SyncRoot) { foreach (var item in list) { // 此时集合被锁住,其他线程的 Add/Remove 会被阻塞 } }我见过不少人第一次用这类集合时踩这个坑,所以这里专门提醒一句:集合本身线程安全,不等于 foreach 也安全。如果你的项目里既要高频读又要高频遍历,建议优先考虑下一节要讲的缓存字典方案,或者干脆用ToArray()快照遍历,但要注意快照也有成本和一致性取舍。
2.4 三个类之间的选型建议
- 数据形态是“键值对”,用
SynchronizedDictionary<TKey, TValue>。 - 数据形态是“元素集合”,需要按位置索引,用
SynchronizedList<T>。 - 数据形态是“元素集合”,只需要去重和快速包含判断,用
SynchronizedSet<T>。 - 如果数据量极小(几十个元素内)且读写极高频,加锁集合和并发集合的差距几乎可以忽略,优先用读代码更直观的那个。
这套基础家族非常适合做多线程环境下的“数据注册中心”,比如客户端连接管理、任务状态表、订阅关系集合。在真实项目中,我用SynchronizedDictionary保存 WebSocket 客户端订阅关系,读写频率大约 1:1,单机支撑两万并发连接时延迟稳定,没有出现锁竞争导致的服务雪崩。
3. CachedSynchronizedDictionary:高频读取场景下的读写锁优化
如果说SynchronizedDictionary解决了“线程安全”问题,那CachedSynchronizedDictionary<TKey, TValue>解决的就是“线程安全且不能太慢”的性能问题。它是我在这套类库中最喜欢的一个类,没有之一。
3.1 工作原理:读多写少时的缓存快照机制
CachedSynchronizedDictionary的灵感来自一个很常见的工程矛盾:有些数据“读的频率极高,写的频率极低”,比如产品的价格表、交易所的合约配置、用户权限缓存。如果每次读取都加锁走字典查找,性能瓶颈全耗在锁上;如果完全不加锁,又有状态一致性问题。
它的做法是:在内部维护一个普通的Dictionary<TKey, TValue>做权威存储,同时维护一个List<KeyValuePair<TKey, TValue>>作为只读缓存。当写入发生时,先更新字典,再把这个键值对同步到缓存列表;当读取发生时,优先从缓存列表里做线性查找,因为列表遍历比字典散列查找在小数据量下更快,而且缓存列表的读取可以用无锁方式访问。
实际的逻辑大致是这样的:
public class CachedSynchronizedDictionary<TKey, TValue> { private readonly Dictionary<TKey, TValue> _dict = new Dictionary<TKey, TValue>(); private readonly List<KeyValuePair<TKey, TValue>> _cache = new List<KeyValuePair<TKey, TValue>>(); private readonly object _syncRoot = new object(); public void Add(TKey key, TValue value) { lock (_syncRoot) { _dict[key] = value; _cache.RemoveAll(pair => pair.Key.Equals(key)); _cache.Add(new KeyValuePair<TKey, TValue>(key, value)); } } public TValue GetValue(TKey key) { // 无锁遍历缓存 foreach (var pair in _cache) { if (pair.Key.Equals(key)) return pair.Value; } return default; } }这里有个细节值得注意:GetValue走的快速路径是完全不加锁的,只有写入才加锁,而且写入时会先把旧的键值对从缓存列表移除再追加新值,保证缓存和字典的数据一致。由于读取路径不碰到任何共享结构的状态变更,多个读取线程同时跑不会有问题。
3.2 失效策略与边界条件
这种设计有一个前提假设:数据量不能太大。因为缓存列表是线性表,查找是 O(n) 复杂度,一旦缓存里塞了几万个键值对,每次无锁查找都会线性扫描,性能会迅速恶化。《Ecng.Collections》这个类在数据量几百到几千键值对时表现极好,上到几万键值对就明显吃力了。
我在实际项目里用的场景是:内存里维护交易所的合约映射表,大约两千个品种的静态信息,行情模块每一笔成交都要查这个表拿合约名称。用传统字典加lock后,高峰期TryGetValue占用了整个模块 25% 的 CPU;换成CachedSynchronizedDictionary后,CPU 占用立刻降到 3% 以下,效果非常明显。但我也试过把订单历史数据放进去,几十万条记录,一次性写入后频繁读取,反而慢得离谱,最后换成了普通ConcurrentDictionary才解决问题。
所以这里给出一个实用的经验值:键值对数量在 5000 以内、读取频率远高于写入频率时,CachedSynchronizedDictionary 是最优解;键值对数量超过 20000,或者写入频率超过每秒数百次,建议放弃缓存列表,回到同步字典或并发字典。
3.3 与 ConcurrentDictionary 的对比
很多人问:.NET 自带的ConcurrentDictionary<TKey, TValue>不香吗?为什么还要用这个缓存字典?
它们的设计哲学完全不同。ConcurrentDictionary追求的是高并发下的无锁读和细粒度锁写,内部用分段锁 + 原子操作,适合“多线程同时读、多线程同时写”的均匀竞争场景。CachedSynchronizedDictionary追求的是“读线程无锁、写线程全锁”,在“读绝大多数、写极少数”的场景下,它的无锁读路径比ConcurrentDictionary的原子读还要快,因为后者虽然不用锁,但仍要进入散列桶查找,还要处理Volatile内存屏障。
举个例子,同样是 2000 个键的字典,每秒 100 万次读取、100 次写入,ConcurrentDictionary大概需要 80 毫秒完成 10 万次读,CachedSynchronizedDictionary只需要 30 毫秒。但如果写的频率上升到每秒 1 万次,缓存字典的锁竞争会迅速超过ConcurrentDictionary,后者依然保持稳定。所以用之前先想清楚你的读写比例,想清楚再决定。
4. 有序与优先级:OrderedDictionary 和 PriorityQueue 的实战用法
线程安全集合解决的是“多线程并发”的问题,但有些场景还需要“有序”和“按优先级取数”。Ecng.Collections里这两个容器值得单独拿出来讲一讲。
4.1 OrderedDictionary:保持插入顺序的键值结构
.NET 自带的Dictionary<TKey, TValue>不保证遍历顺序,SortedDictionary<TKey, TValue>又只能按键排序。如果你想“按插入顺序遍历,同时又能通过键快速取值”,原生类库没有直接可用的方案,OrderedDictionary<TKey, TValue>填补的正是这个空缺。
它的内部实现是一个List<KeyValuePair<TKey, TValue>>加一个Dictionary<TKey, int>索引。列表按插入顺序存储所有键值对;字典保存“键 → 列表索引”的映射。这样按索引遍历是 O(n),按键查找是 O(1),删除键值对时,把列表最后一个元素挪到被删位置,更新索引,保持紧凑。
我自己用过它来维护一个“策略执行顺序表”,每个策略有一个名称和配置对象,需要按用户添加策略的顺序依次执行,同时外部又要能随机器通过名称快速拿到某个策略的配置。用OrderedDictionary后代码非常干净,不用自己维护两个结构的一致性。
var ordered = new OrderedDictionary<string, StrategyConfig>(); ordered.Add("breakout", new StrategyConfig { Lots = 2 }); ordered.Add("ma_cross", new StrategyConfig { Lots = 1 }); ordered.Add("grid", new StrategyConfig { Lots = 5 }); // 按插入顺序遍历 foreach (var pair in ordered) { Console.WriteLine(pair.Key + " -> " + pair.Value.Lots); } // 按键快速取值 var cfg = ordered["grid"];需要注意:OrderedDictionary不是线程安全的,多线程环境要外部加锁。另外如果你需要“按键排序”而不是“按插入排序”,它并不合适,请继续用SortedDictionary。
4.2 PriorityQueue:按处理顺序保证高优任务先行
排队处理的场景经常会遇到优先级问题。比如基金行情推送里有普通行情和紧急风控消息,风控消息必须优先处理。PriorityQueue<T>就是干这个的。
这个类的设计思路是:内部维护一个二叉堆,每次Enqueue时把元素插入堆尾并向上调整,Dequeue时从堆顶取出最小值并向下调整。比较器可以通过构造函数传入,也可以传一个键选择函数,让值对象自动映射出优先级键。默认是最小堆,也就是优先级数值越小,越先出队。
下面是一个典型的用法:
var priorityQueue = new PriorityQueue<OrderMessage, DateTime>(msg => msg.CreateTime); priorityQueue.Enqueue(new OrderMessage { Id = 1, CreateTime = DateTime.UtcNow.AddSeconds(3) }); priorityQueue.Enqueue(new OrderMessage { Id = 2, CreateTime = DateTime.UtcNow.AddSeconds(1) }); priorityQueue.Enqueue(new OrderMessage { Id = 3, CreateTime = DateTime.UtcNow.AddSeconds(2) }); var first = priorityQueue.Dequeue(); // Id = 2,最早创建时间,优先处理我实际用它的场景是:网关服务收到券商回执和用户手动撤单指令,撤单指令必须排在普通回执前面。我定义了一个PriorityEnum,撤单指令的数值设成 0,普通回执设成 1,然后所有消息进PriorityQueue,消费线程从队列取一条处理一条,优先级天然得到保证,代码改动只有消息入口和出口两处。
使用优先级队列要特别注意一点:它只保证 Dequeue 时返回优先级最高的一条,不保证相同优先级内部遵循 FIFO。如果你的业务要求同一优先级里“先到先得”,需要额外增加一个序号字段作为次级比较器,否则不同线程入队的先后顺序可能被堆调整打乱。
4.3 这两个类在项目里的组合使用
在比较完整的项目里,我常看到OrderedDictionary和PriorityQueue配合使用:一个维护静态层级结构,一个处理动态任务流。比如“策略框架启动时用有序字典按配置顺序加载策略模块,运行时用优先级队列调度策略信号”。它们一个解决“数据摆放顺序”,一个解决“处理执行顺序”,互补性很强,这一点很容易被忽略。
5. 容易被忽视的辅助类:DisposableSet、WeakObject 与其他实用扩展方法
Ecng.Collections里还有一些不那么显眼,但关键时刻能帮大忙的工具类。我用过之后觉得有必要专门拉出来分享。
5.1 DisposableSet:把“集合生命周期管理”交给容器
先问一个很实际的问题:你的代码里有没有出现过“用完一批订阅对象,必须逐个调用 Dispose,少调用一个就内存泄漏”的情况?传统写法是维护一个List<IDisposable>,然后在Dispose方法里 foreach 全部释放。如果中间某个对象的 Dispose 抛异常,后面的就全断掉了。
DisposableSet<T>解决的是这个问题。它是一个线程安全的集合实现,同时实现IDisposable,当你调用它的Dispose时,它会递归遍历内部所有尚在集合里的元素,逐个调用Dispose,并且会捕获单个元素的异常,继续等待释放其他元素,最大程度上保证“全部释放完再走”。
它不仅限于IDisposable元素,配合T : IDisposable泛型约束,你可以把一组事件订阅器、文件句柄、网络连接都丢进去统一管理。这对写可观测基础设施代码特别有用,比如一个连接管理器要跟踪所有开放的 WebSocket 客户端,清场时逐个关闭,用DisposableSet一行Dispose就把可选释放过程收敛得非常干净。
5.2 WeakObject:给事件订阅装上“弱引用”
在 C# 里,事件是最容易造成内存泄漏的坑之一。经典场景:小对象订阅了大对象的静态事件,小对象生命周期结束后,由于大对象的事件仍持有小对象的委托引用,小对象永远无法被 GC 回收。WeakObject<T>就是专门对付这个问题的,它在内部用WeakReference包装真实对象,事件触发时先尝试取回强引用,如果对象已被回收,则自动跳过调用。
我实现一个“UI 层订阅数据服务事件”的场景时,过去经常需要手动写Unsubscribe来避免内存泄漏。后来我把事件参数改为WeakObject<EventHandler>包装后,订阅方只需要合理地创建订阅、释放引用,不需要每次-=事件就能被回收了。但要注意,弱引用也意味着真实对象可能随时消失,多线程下取回引用时要做空判断,如果对象已经被回收还强行调用,会抛NullReferenceException。
真实项目中我建议这样用:事件发布方是长生命周期服务,订阅方是短生命周期页面或临时对象时,用弱订阅;双方生命周期接近时,不需要引入弱引用,以免增加复杂度。
5.3 那些藏在角落的扩展方法
Ecng.Collections还提供了不少小而美的扩展方法,我整理过一批自己常用的:
| 扩展方法 | 作用 | 典型场景 |
|---|---|---|
AddRange | 一次向集合添加多个元素 | 批量初始化配置 |
AddTo | 把单个元素加到集合并返回该元素 | 链式调用构造数据 |
CreateThreadSafeWrapper | 为任意自定集合对象包一层线程安全外壳 | 快速让旧对象并发安全 |
Batch | 按指定大小分批处理集合 | 批量写入数据库节流 |
ToSynchronized | 将普通集合转为对应的 Synchronized 集合 | 老代码向新模型迁移 |
看着就是些琐碎工具,但它们能让代码少掉很多临时列表和循环。比如Batch,以前我要分批入库 10 万条记录,得手写一个for循环,每 500 条切割一次;现在records.Batch(500)直接交给消费者,代码直观多了。扩展方法这种东西,用久了你是很难回去的。
6. 实际项目中的取舍经验:我把六类场景浓缩成一张表
文章写到这里,核心类的机制和使用方法都过了一遍,但真正重要的是“什么时候用什么”。我自己在多个项目里反复踩过坑之后,总结出了一张内部参考表,结合使用场景和性能经验分享在这里,供你直接抄作业。
| 场景 | 核心需求 | 推荐类型 | 备注 |
|---|---|---|---|
| 多线程读写简单键值表 | 一致性优先,读写均衡 | SynchronizedDictionary | 锁开销最小,代码最直观 |
| 高频读取少量静态数据 | 读多写极少,性能敏感 | CachedSynchronizedDictionary | 键值对别超过 5000 |
| 多线程维护元素集合 | 去重、包含判断、并发安全 | SynchronizedSet | 不要直接 foreach,用 SyncRoot 锁 |
| 保持插入顺序且支持键查找 | 按序遍历 + O(1) 查找 | OrderedDictionary | 非线程安全,自行加锁 |
| 高并发任务按顺序处理 | 按优先级出队 | PriorityQueue | 相同优先级不保证 FIFO |
| 统一管理多个 IDisposable | 生命周期集中释放 | DisposableSet | 逐个释放失败也不中断 |
| 长生命周期事件发布订阅 | 防止短订阅方内存泄漏 | WeakObject | 取回引用时判空 |
这张表背后其实是一条更底层的规律:Ecng.Collections的每个工具都围绕“把数据结构的某一项能力做到极致”展开,有的牺牲写性能换读性能,有的牺牲随机访问换有序性,有的牺牲强引用换可回收性。没有“包治百病”的集合,只有“对症下药”的组合。
另外有两个小建议必须强调一下:
第一,不要在业务代码里到处混用不同集合类型,同一个数据实体最好只选择一个容器结构贯穿完整生命周期,否则后来维护的人会被搞疯,因为不同集合的枚举安全性、并发策略完全不一样。
第二,任何性能优化都要先用Stopwatch或BenchmarkDotNet跑一把再决定。我自己就遇到过“理论上缓存字典更快,实测数据量到了 1 万以后反而更慢”的情况。数据量和读写比例这两个变量,只靠经验猜是不准的。
7. 用 Ecng.Collections 时最容易范的三个错误
这部分算是我吐苦水的时间,但也是读者最容易实际碰到的坑。如果只收藏不做笔记的话,下面这三点建议反复看。
7.1 把所有集合无脑替换成 Synchronized 版本
这是最典型的问题。我看到过有人把项目里所有Dictionary全部换成SynchronizedDictionary,包括只在单线程构建配置时用的临时字典。结果就是配置加载环节多了无数毫无必要的锁操作,启动时间被拉长了好几百毫秒,代码也更难读。
正确姿势是:先明确“这段数据真的会被多个线程同时访问吗?”。如果答案是否,保留普通集合。如果答案是“只有被测线程写、多个线程读”,那也要考虑是不是用不可变集合或者直接返回快照更合理。加锁不是银弹,只在有并发风险时加。
7.2 遍历 Synchronized 集合时不加锁
前面已经反复提过,foreach一个SynchronizedList或SynchronizedSet时,如果不手动锁住SyncRoot,还是有概率抛“集合已修改”的异常。这个坑在真实的项目中非常隐蔽,因为很多偶发异常只在业务高峰期出现,平时测试根本跑不出来。
我的习惯是写一个扩展方法,专门做“锁内快照遍历”,对外返回一个数组或者列表:
public static List<T> LockedSnapshot<T>(this SynchronizedList<T> list) { lock (list.SyncRoot) { return list.ToList(); } }这样业务代码里调用统一走快照,不直接碰枚举器,既稳定又清晰。
7.3 忽视了 IDisposable 集合的释放顺序
DisposableSet虽然能自动释放内部元素,但它自己也是一个IDisposable。如果你把它嵌套在另一个using块里,千万别在外部对象释放之后再去访问内部元素,否则会出现“容器已释放但元素还活着”的错乱状态。正确的释放顺序永远是:先停止所有业务线程对集合的读写,再整体Dispose集合,最后销毁外部容器。这个顺序一旦搞反,排查起来非常折磨。
8. 几个我可以直接分享的实战配置示例
纸上谈兵差不多了,我贴几个可以直接粘进项目里跑的示例代码,方便你上手时有个参照。
8.1 连接管理器:SynchronizedDictionary + DisposableSet
这是我在网关层常用的组合:一个SynchronizedDictionary<string, ClientSession>保存连接,一个DisposableSet<IDisposable>保存所有清理句柄。
public class GatewaySessionManager : IDisposable { private readonly SynchronizedDictionary<string, ClientSession> _sessions = new SynchronizedDictionary<string, ClientSession>(); private readonly DisposableSet<IDisposable> _disposables = new DisposableSet<IDisposable>(); public void AddSession(string id, ClientSession session) { _sessions.Add(id, session); _disposables.Add(session); } public void Dispose() { _disposables.Dispose(); } }这里注意:DisposableSet里的元素可以是同一个ClientSession,它在被释放时并不会自动从字典里移除,所以你需要在Dispose里额外调用_sessions.Clear()或者让Dispose之前业务侧先摘除字典项。这个顺序保护了重复释放。
8.2 多线程任务队列:CachedSynchronizedDictionary + PriorityQueue
模拟一个“一分钟收一次配置、每秒钟来一千个任务”的调度中心:
public class TaskDispatcher { private readonly CachedSynchronizedDictionary<string, TaskConfig> _configCache = new CachedSynchronizedDictionary<string, TaskConfig>(); private readonly PriorityQueue<TaskItem, DateTime> _queue = new PriorityQueue<TaskItem, DateTime>(item => item.PriorityTime); public void RefreshConfig(string key, TaskConfig cfg) { _configCache.Add(key, cfg); } public TaskConfig GetConfig(string key) { return _configCache.GetValue(key); } public void EnqueueTask(string key, DateTime priorityTime) { _queue.Enqueue(new TaskItem { Key = key, PriorityTime = priorityTime }); } }这个组合在实时调度系统里非常实用:配置更新低频且读取高频,用缓存字典;任务按时间戳优先级排队,用优先队列。两个容器互不干扰,各自解决各自的问题。
8.3 使用 WeakObject 做一个不泄漏的事件总线
假如我有一个MarketDataService,希望订阅方页面销毁后自动断开,代码可以这样组织:
public class MarketDataService { private readonly List<WeakObject<Action<PriceTick>>> _subscribers = new List<WeakObject<Action<PriceTick>>>(); public void Subscribe(Action<PriceTick> subscriber) { _subscribers.Add(new WeakObject<Action<PriceTick>>(subscriber)); } public void Publish(PriceTick tick) { foreach (var weak in _subscribers.ToList()) { var target = weak.Target; if (target != null) { target(tick); } else { _subscribers.Remove(weak); } } } }这段代码用到了两个Ecng.Collections之外的概念:弱引用和“发布时清理”。它并不是万无一失,比如在foreach的同时修改列表会有竞态,真实项目里我会再套一层SynchronizedList并锁内快照,但不影响理解WeakObject的核心用法:取回引用,判空,再调用;为空则顺手清理垃圾订阅。
9. 最后再分享一点个人心得
我用Ecng.Collections的时间不算短,前后在四五个项目里实际跑过。最初吸引我的确是因为它能解决并发集合异常,但真正留我下来的原因是它的代码风格非常“实用主义”——每个类都有明确的使用边界,不搞无意义的抽象,方法命名也往往直指核心操作。
要提醒的是,类库虽好用,但依赖它的 API 时最好在内部封装一层,别让业务代码直接散落使用。比如我可以把SynchronizedDictionary包装进一个SubscriptionStore类,对外只暴露业务语义的Register、Unregister、GetSubscribers方法。这样万一未来项目要切换到ConcurrentDictionary或者其他方案,业务层一行不用改,只换内部实现。这个习惯帮我避过不少次需求变更的坑。
你如果现在正被多线程集合的奇怪异常困扰,或者只是想让自己的服务在高并发状态更稳一点,我非常建议把SynchronizedDictionary和CachedSynchronizedDictionary先加到代码里跑一遍,感受一下两者在实际负载下的差异。其余的类按需取用就好,不要一股脑全部套上,过度设计永远是性能杀手。