C# 异步事件与发布-订阅架构学习笔记

C# 异步事件与发布-订阅架构学习笔记

主题来源:网关服务中 OnNodeRedTelemetryReceivedAsync 事件处理器 —— 一条从"数据采集 → 外部进程处理 → 回传 → 路由 → 落库"的完整异步事件链路。
本文去项目化,提炼普适性的 C# / 架构知识点。


目录

  1. 事件(Event)机制
  2. 异步事件:Func<T, Task> 委托
  3. 事件的完整生命周期:定义-订阅-触发
  4. 事件处理器(Handler)写法规范
  5. 防御性编程:事件链路中的常见坑
  6. 发布-订阅(Pub/Sub)架构模式
  7. MQTT 与进程间通信(IPC)
  8. 数据回传"路由器"模式
  9. 线程安全与并发注意事项
  10. 最佳实践清单

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,保证处理器执行完成后才继续
  • 缺点:破坏了标准 EventHandlersender, 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);   // 触发所有订阅者
}

流程:

  1. 事件源(如 MQTT 消息、定时器、用户操作)到达
  2. 发布者把数据包装成事件参数
  3. 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 路由器模式的通用要点

  1. 路由键要稳定且唯一:用 ID 而非名称(名称可能重复/修改)
  2. 查找索引化:数据量大的话,FirstOrDefault 遍历是 O(n),应改用字典 ConcurrentDictionary<TKey, T> 达到 O(1)
  3. 归属对象持有上下文:数据最终交给谁,谁就要能"理解"它(本例中只有 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;
    }
}

posted @ 2026-08-17 10:08  古月秋筠  阅读(5)  评论(0)    收藏  举报