C# 异步事件与发布-订阅架构学习笔记
C# 异步事件与发布-订阅架构学习笔记
主题来源:网关服务中
OnNodeRedTelemetryReceivedAsync事件处理器 —— 一条从"数据采集 → 外部进程处理 → 回传 → 路由 → 落库"的完整异步事件链路。
本文去项目化,提炼普适性的 C# / 架构知识点。
目录
- 事件(Event)机制
- 异步事件:Func<T, Task> 委托
- 事件的完整生命周期:定义-订阅-触发
- 事件处理器(Handler)写法规范
- 防御性编程:事件链路中的常见坑
- 发布-订阅(Pub/Sub)架构模式
- MQTT 与进程间通信(IPC)
- 数据回传"路由器"模式
- 线程安全与并发注意事项
- 最佳实践清单
1. 事件(Event)机制
1.1 什么是事件
事件是 C# 实现发布-订阅(Pub/Sub)的核心语言特性:
- 发布者(Publisher):定义并触发事件,不知道也不关心谁在监听
- 订阅者(Subscriber):通过
+=注册回调,事件触发时被调用 - 解耦:双方互不引用,通过事件"契约"(委托签名)连接
1.2 事件基于委托
// 1. 自定义委托类型(旧写法)
public delegate void StatusChangedHandler(object sender, StatusChangedEventArgs e);
public event StatusChangedHandler StatusChanged;
// 2. 使用内置泛型委托(现代推荐)
public event EventHandler<StatusChangedEventArgs>? StatusChanged;
事件声明包含两部分信息:
| 部分 | 含义 |
|---|---|
event 关键字 |
表明这是一个事件,外部只能 +=/-=,不能直接赋值或调用 |
| 委托类型 | 规定事件处理器的签名(参数与返回值),是发布者与订阅者之间的"合同" |
关键区别:
public delegate <签名>声明的委托字段可以被外部任意调用;加上event后则被编译器保护——只有声明它的类内部可以触发(Invoke)。
1.3 触发事件的标准写法
public void RaiseSomething()
{
// 标准模式:先复制到局部变量再判空,避免竞态
EventHandler<StatusChangedEventArgs>? handler = StatusChanged;
if (handler != null)
{
handler(this, new StatusChangedEventArgs { ... });
}
}
现代 C# 写法则更简洁:
StatusChanged?.Invoke(this, new StatusChangedEventArgs());
2. 异步事件:Func<T, Task> 委托
2.1 问题背景
标准 EventHandler<T> 委托的返回类型是 void,最多只能同步执行。当事件处理器需要做耗时的 IO(写数据库、调远程 API、发消息)时,同步 void 会阻塞触发方所在线程。
2.2 两种异步事件的实现方式
方式一:委托直接声明为异步签名(本笔记主角)
// 发布者:事件签名直接定义为 Func<PlayloadData, Task>
public event Func<PlayloadData, Task>? DataReceived;
特征:
- 返回
Task,订阅者可以async+await - 触发方也
await,保证处理器执行完成后才继续 - 缺点:破坏了标准
EventHandler的sender, e约定(不过很多内部系统并不在乎)
方式二:保留 EventHandler,处理器内部 fire-and-forget
public event EventHandler<PlayloadData>? DataReceived;
// 订阅方
DataReceived += async (s, e) => { await DoWorkAsync(e); };
风险:async void(匿名 lambda 转成 void 委托)异常无法被捕获,会直接崩溃进程。
2.3 为什么 Func<T, Task> 模式更好
| 维度 | async void / void 事件 |
Func<T, Task> 事件 |
|---|---|---|
| 异常传播 | 异常逃逸到线程池,无法捕获,常导致进程崩溃 | 异常通过 Task 返回,触发方可以 try-catch |
| 完成时序 | 触发方不知道处理器何时完成 | await 保证按序完成 |
| 单元测试 | 难以等待 | 直接 await 即可 |
| 多个订阅者 | 全部并发触发,顺序不可控 | 逐个 await,可按需控制 |
2.4 完整示例
// 发布者
public class MessageBus
{
public event Func<TelemetryData, Task>? OnTelemetryReceived;
public async Task DispatchAsync(TelemetryData data)
{
if (OnTelemetryReceived != null)
{
await OnTelemetryReceived(data);
}
}
}
// 订阅者
public class TelemetryHandler
{
private readonly MessageBus _bus;
public TelemetryHandler(MessageBus bus)
{
_bus = bus;
_bus.OnTelemetryReceived += HandleAsync; // 订阅
}
private async Task HandleAsync(TelemetryData data) // 签名必须匹配 Func<TelemetryData, Task>
{
await SaveToDbAsync(data);
}
}
记住:事件签名是
Func<T, Task>,那么处理器必须是async Task 方法名(T data);不是async void。
3. 事件的完整生命周期:定义-订阅-触发
一个异步事件的生命周期分三步,顺序固定:
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 1. 定义 │ → │ 2. 订阅 │ → │ 3. 触发 │
│ 发布者声明 │ │ 订阅者+=注册│ │ 条件满足时 │
│ event 字段 │ │ (构造时) │ │ Invoke(逐步)│
└────────────┘ └────────────┘ └────────────┘
3.1 定义(发布者)
public event Func<PlayloadData, Task>? OnNodeRedTelemetryReceived;
?表示事件可能没有订阅者- 触发前判断
!= null
3.2 订阅(订阅者,通常在构造函数)
public DeviceService(MessageService messageService)
{
_messageService = messageService;
_messageService.OnNodeRedTelemetryReceived += OnNodeRedTelemetryReceivedAsync;
}
注意事项:
- 订阅时机:对象创建时即可,生命周期与宿主对象一致
- 重复订阅问题:同一个方法被
+=两次,触发时会被调用两次。若宿主对象会被重复创建(如服务反复 Add/Remove),必须记得-=退订,否则数据重复处理 - 订阅/退订成对出现是最佳实践:
+=与-=应出现在 Dispose 中
3.3 触发(发布者,在某事件源回调中)
// 伪代码:某个消息到达回调用
PlayloadData? data = Deserialize(payload);
if (data != null && OnNodeRedTelemetryReceived != null)
{
await OnNodeRedTelemetryReceived(data); // 触发所有订阅者
}
流程:
- 事件源(如 MQTT 消息、定时器、用户操作)到达
- 发布者把数据包装成事件参数
await调用委托,依次执行所有订阅者
4. 事件处理器(Handler)写法规范
一个高质量的事件处理器应具备以下特征:
private async Task HandleDataAsync(PlayloadData data)
{
try
{
// 1. 根据数据定位目标对象(路由)
var target = _items.FirstOrDefault(x => x.Id.Equals(data.TargetId));
// 2. 找不到目标 → 记录日志并丢弃,不抛异常
if (target == null)
{
_logger.LogWarning($"未找到目标[{data.TargetId}],数据丢弃");
return;
}
// 3. 找到目标 → 执行真正的业务
await target.ProcessAsync(data);
}
catch (Exception ex)
{
// 4. 兜底捕获,避免异常向上传播
_logger.LogError(ex, "处理失败");
}
}
4.1 为什么处理器需要 try-catch
事件处理器由发布者调用。若抛异常:
- 在
Func<T, Task>模式下:异常会经由发布者的await抛出,如果发布者自己没捕获,会传到发布者的调用栈 - 在
async void/ 委托回调模式下:异常直接丢失,甚至崩溃进程
所以事件处理器是"最后一道防线",应该在边界处捕获一切异常。
4.2 单一职责
- 处理器只做"路由 + 转交",不做复杂业务
- 真正的落库/计算/聚合逻辑放在目标对象的专用方法里(如
SaveNodeRedTelemetryAsync)
5. 防御性编程:事件链路中的常见坑
5.1 null 检查
| 位置 | 检查 | 原因 |
|---|---|---|
| 触发前 | if (handler != null) |
事件可能无人订阅,直接 Invoke 会 NullReferenceException |
| 数据对象 | if (data != null) |
消息内容可能为空/反序列化失败返回 null |
| 集合查找 | FirstOrDefault 可能返回 null |
必须判空再使用 |
5.2 日志分级
- Warn:可恢复的异常路径(数据找不到、配置缺失)——这类"静默丢弃"必须有日志,否则问题无迹可寻
- Error:真正的执行失败
- 日志要带关键上下文(如目标 ID),方便排障:
$"未找到目标[{data.TargetId}]"
5.3 "找不到就丢弃"的取舍
好处:单条脏数据不会拖垮整个链路(如:设备已删除却仍有回传消息)。
代价:数据可能永久丢失。进阶方案:
- 重试队列(失败 N 次后丢弃)
- 死信主题/表(丢弃前留档)
- 告警通知
选择哪种取决于数据的重要程度。
6. 发布-订阅(Pub/Sub)架构模式
6.1 模式图解
发布者 ──事件──▶ 订阅者A
└▶ 订阅者B
6.2 与直接调用(直接依赖)对比
| 直接调用 | 发布-订阅 |
|---|---|
| 发布者必须知道订阅者的类型 | 发布者只依赖委托签名 |
| 强耦合,改一方必须改另一方 | 松耦合,新增订阅者不改发布者 |
| 编译期静态绑定 | 运行期动态绑定 |
6.3 适用场景
- 一个事件源对应多个处理方(日志、统计、告警各订阅各的)
- 处理方数量/行为可能变化(可插拔)
- 跨层解耦(如基础设施层发事件,业务层订阅)
6.4 局限与注意
- 调试困难:调用链不直观,"谁订阅了?"需要全局搜索
- 顺序不保证:多个订阅者的执行顺序依赖订阅顺序,勿依赖
- 生命周期:订阅者被 GC 前必须退订,否则事件持有委托引用 → 内存泄漏(事件是最常见的泄漏源之一)
// 典型泄漏写法
class Subscriber
{
public Subscriber(Bus bus) => bus.OnEvent += Handler; // 没有退订!
// bus 长期存活,Subscriber 永远无法被回收
}
7. MQTT 与进程间通信(IPC)
7.1 为什么需要 MQTT
当"发布者"和"订阅者"是两个独立进程(如网关程序 + Node-RED 脚本),C# 事件机制鞭长莫及——进程内内存不共享。此时使用 MQTT:
- 发布者(进程 A)→ MQTT Broker → 订阅者(进程 B)
- Broker 是消息中枢,负责路由与暂存
7.2 MQTT 核心概念
| 概念 | 说明 |
|---|---|
| Topic(主题) | 消息的分类地址,如 /devices/{id}/telemetry |
| 通配符 | + 匹配一级,# 匹配多级,如 devices/+/telemetry 匹配所有设备 |
| QoS | 0 最多一次 / 1 至少一次 / 2 恰好一次,权衡可靠性 |
| Retain | 保留最后一条消息给新订阅者 |
7.3 事件 vs MQTT 的映射关系
| 进程内事件 | MQTT |
|---|---|
event 声明 |
Topic 约定 |
+= 订阅 |
Subscribe(topic) |
| Invoke 触发 | Publish(topic, payload) |
| 委托签名 | Payload 的 JSON 结构 |
设计的思路是一致的,只是传输介质不同:事件用内存,消息用网络。
8. 数据回传"路由器"模式
8.1 场景
业务数据经过外部进程处理(计算、映射、富化)后回传,而外部进程不知道数据属于哪个内部对象。需要一层"路由器":根据数据中的标识字段,把数据转交到正确归属对象。
回传消息
│ 包含: TargetId + Payload
▼
路由器: 根据 TargetId 找到持有目标状态的业务对象
│ 找不到 → 日志 + 丢弃
▼
转交: target.ProcessAsync(payload)
8.2 本例中的体现
DeviceThread? deviceThread = DeviceThreads.FirstOrDefault(
x => x.Device.DeviceId.Equals(data.DeviceId)); // 路由:按 DeviceId 找线程
if (deviceThread == null) { /* 丢弃 */ }
await deviceThread.SaveNodeRedTelemetryAsync(data); // 转交:让归属对象处理
8.3 路由器模式的通用要点
- 路由键要稳定且唯一:用 ID 而非名称(名称可能重复/修改)
- 查找索引化:数据量大的话,
FirstOrDefault遍历是 O(n),应改用字典ConcurrentDictionary<TKey, T>达到 O(1) - 归属对象持有上下文:数据最终交给谁,谁就要能"理解"它(本例中只有 DeviceThread 知道设备的存储配置)
// 优化示例:字典索引替代列表遍历
private readonly ConcurrentDictionary<Guid, DeviceHandler> _handlers;
// 注册时
_handlers[device.DeviceId] = handler;
// 路由时
if (_handlers.TryGetValue(data.DeviceId, out var handler))
{
await handler.ProcessAsync(data);
}
else
{
_logger.LogWarning($"[路由]未找到处理器 [{data.DeviceId}],数据丢弃");
}
9. 线程安全与并发注意事项
9.1 事件链路的并发风险点
- 事件可能被多个线程同时触发(如多个 MQTT 消息并发到达)
- 处理器中使用的共享集合必须线程安全
9.2 常见做法
| 场景 | 推荐 |
|---|---|
| 只读查找/遍历频繁更新的集合 | ConcurrentDictionary |
| 先进先出队列 | ConcurrentQueue<T> |
| 需要计数 | Interlocked / ConcurrentDictionary |
| 一对多注册表 | ConcurrentDictionary<TKey, THandler> |
9.3 处理器内部的串行化
若要求同一数据源的处理器按顺序执行(防止乱序落库),可加锁或使用单消费队列:
private readonly SemaphoreSlim _gate = new(1, 1);
private async Task HandleAsync(Data data)
{
await _gate.WaitAsync();
try
{
await ProcessAsync(data);
}
finally
{
_gate.Release();
}
}
MQTT 自带 QoS=1 能保证"至少一次",但不保证顺序;业务上要防乱序就自己加串行化。
10. 最佳实践清单
事件设计
处理器编写
架构设计
附:本文核心代码示例(可直接运行的最小案例)
using System;
using System.Threading.Tasks;
// ============ 发布者 ============
public class Bus
{
// 定义异步事件
public event Func<Payload, Task>? OnDataReceived;
public async Task SendAsync(Payload data)
{
// 触发(若无人订阅则跳过)
if (OnDataReceived != null)
{
await OnDataReceived(data);
}
}
}
// ============ 事件参数 ============
public class Payload
{
public Guid TargetId { get; set; }
public string Body { get; set; } = "";
}
// ============ 订阅者 ============
public class Router
{
private readonly Bus _bus;
private readonly Dictionary<Guid, IHandler> _handlers = new();
public Router(Bus bus)
{
_bus = bus;
_bus.OnDataReceived += HandleAsync; // 订阅
}
// 路由 + 防御
private async Task HandleAsync(Payload data)
{
try
{
if (!_handlers.TryGetValue(data.TargetId, out var handler))
{
Console.WriteLine($"[WARN] 未找到处理器 {data.TargetId},丢弃");
return;
}
await handler.ProcessAsync(data);
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] {ex.Message}");
}
}
public void Register(Guid id, IHandler handler) => _handlers[id] = handler;
}
public interface IHandler
{
Task ProcessAsync(Payload data);
}
// ============ 调用演示 ============
public static class Demo
{
public static async Task Run()
{
var bus = new Bus();
var router = new Router(bus);
router.Register(Guid.NewGuid(), new MyHandler());
await bus.SendAsync(new Payload { TargetId = Guid.NewGuid(), Body = "test" }); // 未找到→Warn
}
}
public class MyHandler : IHandler
{
public Task ProcessAsync(Payload data)
{
Console.WriteLine($"处理: {data.Body}");
return Task.CompletedTask;
}
}

浙公网安备 33010602011771号