c#发布订阅应优先用channels或mediatr,避免手写eventbus;eventhandler仅适用于ui内部通信,否则易致内存泄漏、线程阻塞;mediatr通过inotification多播+管道中间件支持异步与拦截;手写需用concurrentbag配合trygettarget清理,防collection被修改异常。

直接说结论:C# 里实现发布订阅,别从零手写 EventBus——用 System.Threading.Channels 或成熟的 MediatR 更稳;自己造轮子容易踩线程安全、内存泄漏、泛型协变、订阅生命周期这四个坑。
为什么不用 EventHandler + event 关键字?
它只支持单播、不支持异步发布、无法动态管理订阅者、事件引用不释放会导致内存泄漏。比如你用 += 订阅一个窗体关闭后还活着的对象,那个对象就永远被根引用着。
常见错误现象:ObjectDisposedException 在发布时抛出,或 UI 线程卡死(因为事件同步调用且没做异常隔离)。
实操建议:
- 仅用于 UI 控件内部通信(如 Button.Click),别跨模块/跨层用
- 若必须用,务必配对
-=解订阅,且解订逻辑要放在确定的销毁点(如Dispose()或OnClosed()) - 避免在 lambda 中捕获
this,改用弱引用或显式解订
MediatR 是怎么解决“一对多+异步+拦截”的?
它把发布订阅拆成两层:通知(INotification)和处理(IRequestHandler / INotificationHandler<t></t>),靠依赖注入容器自动发现所有处理器。
关键参数差异:
-
INotification不带返回值,天然支持多播;IRequest<tresponse></tresponse>只允许一个处理器,强制单响应 - 默认同步执行;加
await _mediator.Publish(new MyNotification())才走异步管道 - 中间件(如日志、事务)通过
IPipelineBehavior<trequest tresponse></trequest>注入,不是靠继承
性能影响:每次 Publish 都会反射查找所有 INotificationHandler<t></t> 实现——所以别在高频循环里发通知;建议用 static 缓存处理器列表或启用 MediatR 的源生成器(MediatR.SourceGenerators)。
手写轻量 EventBus 时,ConcurrentDictionary 和 WeakReference 怎么配合?
核心矛盾:强引用订阅者 = 内存泄漏;弱引用 = 订阅者可能被 GC 掉但字典里还留着键值对。
实操建议:
- 键用
Type(事件类型),值用ConcurrentBag<weakreference>></weakreference>,而非List<action>></action> - 发布前遍历集合时,先调
TryGetTarget(out var handler),为null就Remove这个弱引用 - 别用
lock包裹整个发布流程——它会阻塞其他线程订阅/退订;只锁清理弱引用那一步 - 如果事件需要按顺序处理,加个
Order属性字段,用OrderBy(x => x.Order)排序后再调用(注意排序开销)
容易踩的坑:WeakReference 对于静态方法或 lambda 表达式无效(它们没有实例生命周期),这种场景只能靠业务层手动管理订阅生命周期。
发布时遇到 InvalidOperationException: Collection was modified 怎么定位?
这是手写 EventBus 最常崩的点:一边在遍历 handlers 列表,一边另一个线程调了 Unsubscribe 把其中一项删了。
解决方案分三层:
- 底层:用
ConcurrentQueue<t></t>或ImmutableArray<t></t>替代List<t></t>,发布前.ToImmutableArray()快照一份 - 中层:给
Subscribe/Unsubscribe加细粒度锁(如ReaderWriterLockSlim),读多写少时比lock更高效 - 上层:根本别让退订发生在发布过程中——要求所有
Unsubscribe必须在非发布上下文(如构造函数、IDisposable.Dispose)里调用
调试技巧:在发布循环里加 Thread.Sleep(1) 模拟延时,再开多个线程猛发 + 猛退订,能快速复现问题。
真正难的不是怎么发消息,而是谁来决定什么时候退订、退订是否及时、事件参数要不要深拷贝、跨域发布要不要序列化——这些都得结合你的模块边界和生命周期策略来定,没有通用解。










