应避免用 public event eventhandler 做跨生命周期通信,因其易致内存泄漏和 nullreferenceexception;须配对 += 与 -=,将 -= 放入 dispose 或生命周期钩子中;推荐显式缓存委托调用、用 iobservable 替代事件以支持组合与自动释放、用 channel 处理有节奏的数据流。

别用 public event EventHandler 做跨生命周期通信——90% 的内存泄漏和 NullReferenceException 都从这里开始。
为什么 += 订阅后必须配对 -= 取消订阅
事件本质是委托链,+= 会让发布者(如窗体、服务类)持有一个指向订阅者方法的引用。如果订阅者(比如某个临时 ViewModel 或后台任务对象)比发布者先销毁,而没调用 -=,那它就永远无法被 GC 回收。
- 常见现象:UI 页面关闭后,内存占用不降,
Finalize不触发,调试时发现对象仍被EventHandler引用 - 特别危险场景:在 UserControl、Page、IHostedService 中订阅全局单例服务的事件
- 不能靠“对象析构”补救:C# 析构函数不保证执行时机,且无法在其中安全调用
-=(可能已处于终结线程) - 正确做法:把
-=放进订阅者的IDisposable.Dispose()或页面OnNavigatedFrom()/FormClosed等明确生命周期钩子中
如何安全触发事件避免 NullReferenceException
?.Invoke() 在 .NET 6+ 是原子读,但旧项目或高并发下仍可能出问题——因为 ?.Invoke() 读取委托字段和调用是两步操作,中间可能被其他线程清空。
- 推荐写法:
var handler = MyEvent; if (handler != null) handler(this, e);—— 显式缓存委托引用,避免竞态 - 不要在 UI 线程直接调用耗时逻辑:事件处理方法应轻量,重活交给
Task.Run或await异步方法 - 若需跨线程更新 UI,别手动
Control.Invoke:改用SynchronizationContext.Current?.Post()或 MVVM 框架的调度器
用 IObservable<t></t> 替代传统事件的三个实际好处
当你的“事件”需要组合、过滤、限流、或绑定到响应式 UI(如 ReactiveUI、Avalonia),IObservable<t></t> 比 event 更可控。
- 自动管理生命周期:
Subscribe()返回IDisposable,存为字段,Dispose()时统一释放,不用记哪个-=漏了 - 天然支持背压与取消:
Take(5).Where(x => x > 0).Timeout(...)这类操作,event做不到 - 可桥接传统事件:
Observable.FromEventPattern<eventargs>(h => button.Click += h, h => button.Click -= h)</eventargs>,包装后就能用 Rx 操作符 - 注意:不要在
Subscribe()内部抛异常——会终止整个流;用ObserveOn(Scheduler.Default)控制执行上下文
什么时候该换 Channel<t></t> 而不是死磕事件
如果你的“事件”实际是数据流(比如传感器采样、日志批量推送、消息队列消费),且需要缓冲、限速、或消费者处理慢时让生产者等待,Channel<t></t> 是更底层也更可靠的选择。
- 典型信号:你开始手写
ConcurrentQueue+ManualResetEvent+ 锁来控流 → 直接换Channel.CreateBounded<t>()</t> -
ChannelReader.ReadAllAsync()是异步枚举,天然适配async/await,比轮询Dequeue干净得多 - 关键配置:
FullMode = BoundedChannelFullMode.Wait防止内存爆炸;SingleWriter = true提升吞吐(多数场景适用) - 坑点:别在 UI 线程
await channel.Writer.WriteAsync()—— 可能死锁;加cancellationToken并确保它来自页面级CancellationTokenSource
真正难的不是写对语法,而是判断该用 event、IObservable<t></t> 还是 Channel<t></t> —— 核心只看一点:这个“通知”要不要携带数据、有没有消费节奏约束、以及谁的生命周期更长。










