ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C#观察者模式实战:从event到多线程与异步避坑指南

C#观察者模式实战:从event到多线程与异步避坑指南 我刚接触C#/.net那阵子对“观察者模式”的理解一直停留在概念上什么一对多、什么解耦背得滚瓜烂熟真到写业务代码时还是老老实实把一堆调用堆在方法里。直到有一次要做一个票务核销系统核销成功之后本地缓存要更新、用户要收到通知、门店要对账、后台要记操作日志如果所有逻辑全部写死在核销方法里这个方法会膨胀到完全没法维护。那之后我认真把观察者模式在上位机、后台服务、业务系统里都过了一遍才算真正玩明白。这篇文章就围绕C#/.net下的观察者模式从“到底是什么”讲到“具体怎么写”再把多线程、异步、事件泄漏这些实战里最容易翻车的地方一并整理出来。适合刚入门设计模式的同学也适合那些用了很久event但没搞懂背后原理的开发者看完可以直接拿去项目里用。1. 观察者模式到底解决什么问题1.1 先从一个核销系统的例子说起假设你负责一个生活服务平台的核销功能用户到店之后出示一个核销码店员在POS机上输入这个码系统要做三件事扣减平台侧的券库存、给用户手机推送一条“核销成功”的通知、把对账流水写入数据库。最直觉的写法是这样的public class TicketService { public void Verify(string code) { // 1. 扣减库存 DeductStock(code); // 2. 推送通知 PushNotify(code); // 3. 写对账流水 WriteAuditLog(code); } }这个写法在业务只有三五个订阅方的时候其实问题不大。但真实项目里核销成功这个动作的“下游”是会不断增加的今天要加一个积分发放明天要接一个短信通知后天可能还要推送消息给商家App。每加一个需求你就要打开TicketService在Verify方法里再塞一行调用。更麻烦的是这些下游逻辑还会互相独立用户推送失败不能影响库存扣减库存扣减失败对账流水也得留下痕迹。如果全写在同一个方法里一个异常就能让整条链路崩掉。观察者模式解决的就是这个结构问题核销成功是一个“事件”谁关心这个事件谁自己去订阅。TicketService只负责发出一句“我核销成功了”至于谁会来听、听完干什么它完全不关心。1.2 三个角色被观察者、观察者、事件把观察者模式拆开看永远只有三个角色被观察者Subject业务动作发生的源头比如上面的TicketService。它维护一个订阅者列表在状态变化时通知所有订阅者。观察者Observer对状态变化感兴趣的一方实现一个统一的回调接口或者订阅一个事件。事件/通知Event数据载体把发生变化的上下文信息传递给观察者。用生活里的话说被观察者就是“广播电台”观察者就是“听广播的人”事件就是“正在播放的节目”。电台不知道谁在听听的人也不用冲进演播室去问播什么两边只听同一频率就行。C#里对这个模式有天然支持——event关键字就是官方提供的观察者模式实现。我们可以直接用也可以在此基础上扩展更花哨的玩法。2. C#里实现观察者模式的三种姿势2.1 用event和委托实现这是C#的正统写法在C#/.net里最推荐的观察者模式实现方式是使用event。比如把核销系统重构成这样// 事件参数把核销相关的上下文都装进去 public class TicketVerifiedEventArgs : EventArgs { public string Code { get; } public string OrderNo { get; } public DateTime VerifiedTime { get; } public TicketVerifiedEventArgs(string code, string orderNo) { Code code; OrderNo orderNo; VerifiedTime DateTime.Now; } } // 被观察者 public class TicketService { // 声明事件委托类型是标准的事件处理器 public event EventHandlerTicketVerifiedEventArgs? TicketVerified; public void Verify(string code) { // 业务校验、核销主流程... var orderNo GetOrderByCode(code); Console.WriteLine($[TicketService] 核销成功{code}); // 通知所有订阅者 OnTicketVerified(new TicketVerifiedEventArgs(code, orderNo)); } protected virtual void OnTicketVerified(TicketVerifiedEventArgs e) { // 先取到本地变量再判空触发 EventHandlerTicketVerifiedEventArgs? handler TicketVerified; handler?.Invoke(this, e); } }订阅端各自实现自己的处理逻辑不依赖TicketService的改动public class StockSubscriber { public void OnTicketVerified(object? sender, TicketVerifiedEventArgs e) { Console.WriteLine($[库存] 核销码 {e.Code} 对应的券已扣减); } } public class NotifySubscriber { public void OnTicketVerified(object? sender, TicketVerifiedEventArgs e) { Console.WriteLine($[通知] 已向用户推送核销成功消息订单号 {e.OrderNo}); } } public class AuditSubscriber { public void OnTicketVerified(object? sender, TicketVerifiedEventArgs e) { Console.WriteLine($[对账] 写入核销流水{e.OrderNo}时间 {e.VerifiedTime}); } }组装订阅关系var ticketService new TicketService(); var stock new StockSubscriber(); var notify new NotifySubscriber(); var audit new AuditSubscriber(); ticketService.TicketVerified stock.OnTicketVerified; ticketService.TicketVerified notify.OnTicketVerified; ticketService.TicketVerified audit.OnTicketVerified; ticketService.Verify(ABC123);执行结果就是三条日志依次打印。以后要增加积分发放只需要新建一个IntegralSubscriber加一行订阅代码TicketService一行都不用改。这就是观察者模式最核心的价值发布方和订阅方的编译期依赖被解除了。2.2 用接口实现适合需要精细控制的场景用event虽然简洁但有个小问题订阅关系藏在代码里没法在运行时灵活增删也没法做批量管理。如果项目里需要观察者能“挂载”也能“摘除”甚至需要支持多个被观察者共享一套观察者逻辑那用接口加List的方式更合适。public interface ITicketObserver { // 返回值是bool用来表示本次处理是否成功 bool HandleVerification(TicketVerifiedEventArgs e); } public class TicketService { private readonly ListITicketObserver _observers new(); public void Attach(ITicketObserver observer) _observers.Add(observer); public void Detach(ITicketObserver observer) _observers.Remove(observer); public void Verify(string code) { // 核销主流程... var args new TicketVerifiedEventArgs(code, GetOrderByCode(code)); Console.WriteLine($[TicketService] 核销成功{code}); foreach (var observer in _observers) { observer.HandleVerification(args); } } }观察者实现对应接口public class StockObserver : ITicketObserver { public bool HandleVerification(TicketVerifiedEventArgs e) { Console.WriteLine($[库存] 扣减商品库存数量{e.Code}); return true; } }这种写法的好处是观察者的生命周期完全由被观察者掌控。但要注意一个问题foreach遍历观察者列表时如果其中一个观察者抛异常后面的观察者都不会执行。所以用接口实现时遍历逻辑里必须自己做异常隔离foreach (var observer in _observers) { try { observer.HandleVerification(args); } catch (Exception ex) { // 记录日志继续执行下一个订阅者 Console.WriteLine($[TicketService] 观察者 {observer.GetType().Name} 执行失败{ex.Message}); } }2.3 用弱事件解决一个经典隐患内存泄漏用event实现观察者模式最容易被忽视的问题就是事件泄漏。C#的event本质上是一个委托字段被观察者持有对观察者方法的引用。也就是说只要被观察者还活着它就会一直拽着观察者不放。典型的翻车场景是一个全局的TicketService或者作为单例存在的事件源里订阅了某个窗体实例的方法。窗体关闭后因为TicketService还引用着窗体里的方法窗体对象永远无法被GC回收内存占用只会越来越高。解决思路有两种。第一种是“忘了移除”在Dispose或窗体关闭事件里执行ticketService.TicketVerified - notify.OnTicketVerified;这是最直接的办法也是大多数项目里够用的办法。第二种是使用弱事件让订阅引用不阻止GC回收.NET里可以用WeakEventManager或者自己封装一个基于WeakReference的弱事件集合public class WeakTicketNotifier { private readonly ListWeakReference _observers new(); public void Subscribe(ITicketObserver observer) { _observers.Add(new WeakReference(observer)); } public void Notify(TicketVerifiedEventArgs e) { // 遍历时注意清理已经失效的弱引用 _observers.RemoveAll(w !w.IsAlive); foreach (var weakRef in _observers) { if (weakRef.Target is ITicketObserver observer) { observer.HandleVerification(e); } } } }弱事件写起来麻烦效率和可靠性也不如强引用所以我个人的建议是项目里优先用显式移除订阅弱事件只用在那些生命周期确实难以控制的全局事件源上。3. 实战中绕不开的三个细节线程、异常、异步3.1 多线程触发事件必须注意线程安全很多事件是在后台线程上触发的。最典型的就是上位机里的串口数据接收——SerialPort.DataReceived事件工作在线程池线程上你在这个事件里更新UI控件直接跨线程访问会抛出异常。自己写事件源时同样要面对多线程问题多个生产者线程同时触发事件多个消费者线程同时订阅或退订处理不好就会出现“事件断链”或者数据不一致。解决办法是在事件的add/remove访问器里加锁public class DataPublisher { private readonly object _syncRoot new object(); private EventHandlerDataEventArgs? _dataReceived; public event EventHandlerDataEventArgs DataReceived { add { lock (_syncRoot) { _dataReceived value; } } remove { lock (_syncRoot) { _dataReceived - value; } } } protected virtual void RaiseDataReceived(DataEventArgs e) { EventHandlerDataEventArgs? handler; lock (_syncRoot) { handler _dataReceived; } // 在锁外触发避免执行订阅者时卡住其他订阅线程 handler?.Invoke(this, e); } }这套写法有两个隐含细节一是add/remove加锁保证订阅关系本身是线程安全的二是触发时先把委托快照拷到局部变量再到锁外执行订阅者。如果在锁内执行订阅者一旦某个订阅者方法体很慢所有想订阅和退订的线程都会堵住那是典型的锁粒度问题。3.2 异常隔离不能因为一个订阅者挂了整条广播断掉事件委托是多播委托默认行为是串行调用所有订阅方法。如果在第三个订阅者执行时抛了异常后面的所有订阅者都不会执行。这在实际项目里往往不是你想要的行为。举个例子核销成功后有五个订阅方第四个订阅者是用户推送推送服务临时不可用抛了一个网络异常。如果不做处理第五个订阅者写对账流水就不会跑对账流水缺失那就是事故了。所以生产级事件触发逻辑建议都走GetInvocationList逐个调用每个订阅者包一层try/catchprotected virtual void OnTicketVerified(TicketVerifiedEventArgs e) { var handler TicketVerified; if (handler null) { return; } // 多播委托内部其实是一个调用列表 foreach (EventHandlerTicketVerifiedEventArgs item in handler.GetInvocationList()) { try { item(this, e); } catch (Exception ex) { // 记录异常继续执行下一个订阅者 LogError(ex); } } }每个订阅者的异常被独立捕获不会中断整条广播链路。代价是异常被吞掉了所以日志必须记录完整方便事后排查。3.3 异步订阅的坑async void要慎用C#事件委托的返回类型是void意味着事件处理器方法不能直接写成async Task。很多人图省事会这样写ticketService.TicketVerified async (sender, e) { await SendNotifyAsync(e.OrderNo); };这样写能编译但本质上是async void。async void方法里的异常不会像async Task那样被调用方捕获而是直接抛到线程池上很容易导致进程崩溃。在类型库里看到“UnobservedTaskException”或者“应用程序崩溃但没有明确堆栈”时多半就是async void埋的雷。我的处理习惯是事件处理器里不直接做异步操作改成记录一个“待处理事件”到队列由后台消费者统一处理。这也是观察者模式在上位机和后台服务里最常见的演化方向。public class AsyncSubscriber { private readonly ChannelTicketVerifiedEventArgs _channel; public AsyncSubscriber(ChannelTicketVerifiedEventArgs channel) { _channel channel; } public void OnTicketVerified(object? sender, TicketVerifiedEventArgs e) { _channel.Writer.TryWrite(e); } }事件处理器只做一次入队真正的异步逻辑在后台循环里执行。这样既不会阻塞事件源也不会让异常乱飞返回结构也干净。4. 观察者模式在实际项目里的落地场景4.1 上位机数据采集串口数据怎么分发给多个处理模块上位机开发里设备上报数据是典型的观察者场景。串口、网口、Modbus、TCP/IP每种协议都可能在一个“数据到达”事件上挂多个处理模块界面图表要刷新、历史数据库要存储、异常判断逻辑要检测、日志系统要记录。我做过一个温控设备的上位机串口每秒上报一次温度数据。早期代码直接在DataReceived事件里把所有处理写完后来要加一个温度趋势图又要在DataReceived里塞代码再后来要加一个超温报警弹窗又继续塞。最后DataReceived方法有400多行我已经不敢动了。重构后用事件把数据分发给三个订阅者serialPort.DataReceived OnDataReceived; void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var data ReadFromPort(); DataPublisher.RaiseDataReceived(data); }温度曲线窗体、数据库存储服务、异常报警器各自订阅DataReceived事件。上位机主窗体只负责组装这些组件并建立订阅关系后续加功能只需要新增订阅者不需要动数据接收主逻辑。实测下来代码维护成本下降了不止一个量级。需要注意串口等硬件事件的触发频率可能很高。如果订阅者里有UI刷新这种重操作建议先对事件做节流或合并比如用System.Threading.Timer做100ms刷新一次避免界面上UI线程被事件风暴打满。4.2 后台服务里的观察者队列消息、推送、日志三方配合在后台服务中观察者模式最常见的搭档是消息队列。比如用RabbitMQ接收业务消息收到消息后同一个消息要触发多种处理。我习惯在消息消费入口处把消息转成一个CLR事件再交给多个订阅者处理一个是业务入库、一个是审计日志、一个是重复消息标记。这种设计的另一个好处是消费逻辑和业务处理逻辑分离。消息队列消费者只管“取消息、确认消息、触发事件”具体怎么处理由订阅者决定。想换队列实现消费者类基本不用动换个组装方式就行。代码如下结构很直观public class RabbitMqConsumer { public event EventHandlerMsgReceivedEventArgs? MsgReceived; public void Start() { // 连接RabbitMQ、设置消费者... // 收到消息后调用OnMsgReceived(new MsgReceivedEventArgs(message)); } } public class OrderHandler { public void OnMsgReceived(object? sender, MsgReceivedEventArgs e) { // 处理订单业务 } } public class AuditHandler { public void OnMsgReceived(object? sender, MsgReceivedEventArgs e) { // 写审计日志 } }组装时把事件方法都注册上消息处理模块就完成了。我在好几个项目里用这个套路代码结构几乎一样换个业务换个订阅者就行非常耐造。4.3 老工程升级到新.NET框架时观察者代码要注意什么这几年不少团队把老项目从.NET Framework往.NET 6/8迁移。观察者模式的代码在迁移过程中通常能平滑通过但有三个地方容易踩坑。第一个是空安全相关。老代码里事件字段很多是裸字段升级到支持可空引用类型的现代.NET后编译器会提示event字段可能为空。以前的写法是public event EventHandler? DataChanged; // 老代码没问号升到新框架后建议统一把所有事件字段声明为可空并在触发处照旧做判空。这不是格式问题而是让静态分析器能帮你找出潜在的NullReference。第二个是BeginInvoke在新框架里的隐患。老.NET Framework代码里有些人会用eventHandler.BeginInvoke(...)做异步触发但在.NET Core里委托的BeginInvoke不再被支持直接抛异常。升级时要用Task.Run或者线程池来替代。第三个是框架包的变化。老项目里的WeakEventManager在WPF里可以用但跨程序集注意引用WindowsBase。升级到新框架时如果项目有跨UI线程的事件触达需求强烈建议用System.Windows.Threading.Dispatcher或者数据流的异步消息通道而不是继续硬啃线程上下文。5. 观察者模式常见问题与排查经验速查5.1 事件只订阅不取消内存一直涨这是观察者模式项目里最高频的问题。绕来绕去通常就两种原因一是长生命周期对象订阅了短生命周期对象的事件二是短生命周期对象订阅了长生命周期对象的事件短对象销毁时忘记退订。排查时可以用内存分析工具看对象引用链如果看到事件源对象指向了大量本应销毁的对象基本就是这里的问题。规避方法也很简单谁订阅谁负责退订。在Dispose方法里统一执行eventSource.SomeEvent - subscriber.Handler;另外订阅时多写一个配对逻辑比如Create和Dispose成对出现这样不容易漏。绝对不要靠GC去解决GC救不了被强引用拽住的托管对象。5.2 UI界面卡顿、跨线程访问异常现象是事件触发后界面卡死或者报“调用线程无法访问此对象因为另一个线程拥有该对象”的异常。原因几乎都是事件串行执行某个订阅者在线程池线程上做了大计算或者订阅者在后台线程里直接操作了UI控件。解决办法有两个方向。一个是把耗时订阅者摘到独立的处理线程事件源只负责快速分发给消息队列另一个是在UI线程的订阅者里做线程切换用Control.BeginInvoke或Dispatcher.Invoke把更新动作丢回UI线程。我更推荐前者——把耗时逻辑从事件链路里剥离UI线程卡顿问题会从根本上变少。5.3 事件触发多次、重复订阅场景是这样的一个页面打开三次事件就订阅了三份每次触发页面上的回调就会执行三遍。原因是订阅代码写在了每次页面加载都会执行的地方但没有先退订。排查看起来很简单但实际问题里往往藏在“间接订阅”里A订阅了B的事件B又订阅了C的事件三层嵌套漏掉一层就会出现重复。我的经验是订阅逻辑一定要集中管理不要在多个地方散落最好在一个方法里完成所有组装和注册。涉及重复注册的场景还可以在订阅访问器里加去重逻辑private EventHandlerTicketVerifiedEventArgs? _ticketVerified; public event EventHandlerTicketVerifiedEventArgs TicketVerified { add { // 如果已经订阅过就不再重复添加 if (_ticketVerified ! null Array.IndexOf(_ticketVerified.GetInvocationList(), value) 0) { return; } _ticketVerified value; } remove _ticketVerified - value; }真正常配合起来的做法是把“事件触发顺序”也做一层管理。虽然C#的多播委托按注册顺序执行但如果你有强顺序依赖比如必须先扣库存再推送通知最好不要依赖注册顺序而是把强顺序步骤合并成一个订阅者在订阅者内部用职责链排好序。观察者模式强调的是解耦强顺序本来就是破坏解耦的信号。5.4 观察者模式不是银弹什么时候不该用最后说点反直觉的经验。观察者模式很好用但不是所有“一拖多”都适合套它。如果事件投递的上下游有强一致性和强顺序要求比如扣库存成功后才能推送通知且扣库存失败通知绝不能发那用观察者模式就会把顺序约束打散到各个订阅者里反而更难保证正确性。判断标准我给三条一订阅者之间是否真正独立谁也不依赖谁的处理结果二是否真的存在运行时增减订阅者的需求或者多个场景复用同一事件源三你能否接受触发的先后顺序不完全受控。三个条件都满足观察者模式就是好选择有一条不满足就需要考虑模块之间更显式的调用关系了。就我个人的习惯来说平时在C#/.net项目里基本是混着用业务事件优先用event关键字这是成本最低、最符合语言习惯的方案需要灵活管理订阅者生命周期的地方用接口加List跨服务、跨进程的“事件”则直接用消息队列让RabbitMQ这样的中间件来承担广播职责。观察者模式的价值从来不在某一套写法本身而是它逼着你去思考“谁真正需要知道这个变化”把这个想清楚代码自然就有了边界。
RELATED READING

延伸阅读

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