CodeGym /課程 /C# SELF /事件驱动编程的优化

事件驱动编程的优化

C# SELF
等級 54, 課堂 2
開放

1. 引言

在大多数典型应用中,事件运行得很快,几乎“零成本”——CLR(Common Language Runtime)对它们的处理进行了极佳的优化。但是,当应用变得庞大,事件数量增多,订阅链变长,性能要求提高时,突然发现:即使是像事件这样“简单”的结构,也可能成为瓶颈。尤其是在实时更新、用户界面(UI)或处理数十万传感器通知的IoT应用中,这一点尤为明显。

在这节课中,我们将探讨:

  • 事件和delegate如何影响性能。
  • 存在哪些瓶颈。
  • 如何编写快速的事件代码,避免影响性能的问题。

.NET中事件的内部结构

如前所述,事件是delegate的封装。delegate是一个特殊对象,包含一个方法列表(invocation list),在调用时会依次调用这些方法。每次触发事件时,CLR会遍历这个列表,同步调用所有方法。(异步调用只有在你手动添加异步代码时才会出现。)

示意图:

[发布者] ----- (event) ---> [Delegate (Invocation List)] --> [处理器1]
                                                           --> [处理器2]
                                                           --> [处理器N]

2. delegate和事件的成本:拆解分析

存储成本

  • 每个delegate都是一个完整的对象。
  • 每个订阅的处理器(订阅方法)会创建一个新的delegate。
  • 订阅越多,创建的对象越多,占用的内存也越大。

在简单场景下,内存泄漏或开销几乎可以忽略。但如果订阅者数以千计,就值得考虑优化了!

调用成本

  • 触发事件等于遍历invocation list。
  • 每个方法都是同步调用(一个接一个)。
  • 如果某个处理器执行繁重任务或长时间“阻塞”,会拖慢整个调用链。

示例:简单实现

public class Counter
{
    public event EventHandler Counted;

    public void Increment()
    {
        // ... 计数逻辑省略
        // 订阅者同步调用!
        Counted?.Invoke(this, EventArgs.Empty);
    }
}

假设有1000个订阅者,每个处理器调用 Thread.Sleep(10),那么事件调用时间就会变成大约10秒……

3. “沉重”的订阅者——性能杀手

为什么处理器要保持“轻量”?

  • 事件是同步调用,调用线程会等待所有处理器完成。
  • 一个慢的处理器会拖慢整个链条。
  • 如果处理器可能抛出异常——后续的处理器可能不会被调用(除非你用try/catch保护)。

演示:

class Program
{
    static void Main()
    {
        var publisher = new Counter();
        // 快速处理器
        publisher.Counted += (s, e) => Console.WriteLine("第一个");
        // 慢速处理器
        publisher.Counted += (s, e) => System.Threading.Thread.Sleep(2000);
        // 另一个
        publisher.Counted += (s, e) => Console.WriteLine("最后");
        
        // 时间测量
        var watch = System.Diagnostics.Stopwatch.StartNew();
        publisher.Increment();
        watch.Stop();
        Console.WriteLine($"所有处理器调用完毕,耗时:{watch.ElapsedMilliseconds} 毫秒");
    }
}

试着运行一下,你会看到明显的暂停。第一个几乎瞬间完成,第二个“延迟”两秒,最后才轮到第三个。

实际建议:

  • 不要把繁重的业务逻辑直接写在事件处理器中!
  • 最好把这类工作放到单独的线程、任务或异步处理器中。

4. 处理器中的异常:性能陷阱

如果某个订阅者抛出异常,事件的后续处理就会中断——后面的处理器可能不会被调用!

publisher.Counted += (s, e) => throw new Exception("错误!");
publisher.Counted += (s, e) => Console.WriteLine("你不会看到这行。");

为了避免被“坏苹果”拖累,建议用手动遍历调用每个处理器,并用try/catch保护每个调用。

改进版事件调用:

protected virtual void OnCounted()
{
    var handlers = Counted?.GetInvocationList();
    if (handlers != null)
    {
        foreach (var handler in handlers)
        {
            try
            {
                ((EventHandler)handler)(this, EventArgs.Empty);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"处理器出错:{ex.Message}");
                // 记录日志或特殊处理
            }
        }
    }
}

这样,事件就更“韧性”——即使某个订阅者出错,其他的依然可以正常工作。

5. 异步(fire-and-forget)事件

如果事件处理可能很慢,有时希望在不同线程或任务中启动处理器,避免阻塞主线程。

方案1:为每个处理器启动一个Task

protected virtual void OnCountedAsync()
{
    var handlers = Counted?.GetInvocationList();
    if (handlers != null)
    {
        foreach (var handler in handlers)
        {
            // fire-and-forget:不等待完成
            System.Threading.Tasks.Task.Run(() =>
            {
                ((EventHandler)handler)(this, EventArgs.Empty);
            });
        }
    }
}

注意!并发问题要小心

  • 如果订阅者共享资源,可能会出现竞态条件(race conditions)。
  • 在fire-and-forget中捕获异常较难。
  • 如果需要等待所有处理器完成,就要收集Task,最后用 Task.WhenAll。

对于UI(WinForms/WPF)来说,切记不要在非UI线程调用事件处理器,否则会抛出 InvalidOperationException。

总之——异步事件设计需要谨慎和周密的考虑!

6. 存储和调用事件的优化

“空”事件:节省内存

如果你的类中有很多事件,大部分很少用(比如UI组件中的大量事件),可以用 EventHandlerList 来优化。

工作原理:

.NET控件(比如WinForms)不会为每个事件都存一个单独的delegate,而是把所有事件存到一个结构(EventHandlerList)中——只有在有订阅者时才存。

手动创建EventHandlerList示例:
using System.ComponentModel; // EventHandlerList在这里

class MyControl
{
    private readonly EventHandlerList _events = new EventHandlerList();

    private static readonly object EventMyEvent = new object();

    public event EventHandler MyEvent
    {
        add    { _events.AddHandler(EventMyEvent, value); }
        remove { _events.RemoveHandler(EventMyEvent, value); }
    }

    protected virtual void OnMyEvent()
    {
        var handler = (EventHandler)_events[EventMyEvent];
        handler?.Invoke(this, EventArgs.Empty);
    }
}

为什么要这样做:可以节省大量内存,不会为未用到的事件创建多余的delegate。

7. 线程安全:避免竞态和死锁

.NET中的事件本身不是线程安全的!在订阅或取消订阅时,另一线程可能同时触发事件,导致delegate变成null,从而引发 NullReferenceException。

最佳实践:

  • 使用操作符 ?.(如 Counted?.Invoke(...))保护空引用。
  • 复杂场景下,使用 lock 来同步访问。

示例:

private readonly object _lockObj = new object();
private EventHandler _myEvent;

public event EventHandler MyEvent
{
    add { lock (_lockObj) { _myEvent += value; } }
    remove { lock (_lockObj) { _myEvent -= value; } }
}

protected virtual void OnMyEvent()
{
    EventHandler handler;
    lock (_lockObj)
    {
        handler = _myEvent;
    }
    handler?.Invoke(this, EventArgs.Empty);
}

什么时候需要这么复杂?

  • 多线程环境(如服务器、多线程解析器等)。
  • 订阅/取消订阅发生在不同线程,事件触发在另一个线程时。

8. 访问器add/remove的自定义实现

在特殊场景(比如需要记录所有订阅或限制订阅数)下,可以自己实现事件的add/remove访问器:

private EventHandler _event;
public event EventHandler MyEvent
{
    add
    {
        if (_event == null || _event.GetInvocationList().Length < 10)
            _event += value;
        else
            Console.WriteLine("限制:最多10个订阅者。");
    }
    remove { _event -= value; }
}

这样可以实现:

  • 自定义逻辑(比如限制订阅数、日志记录等)。
  • 增强线程安全性(结合同步机制)。
  • 监控订阅/取消订阅行为。

9. 一些实用技巧

使用ValueTuple和lambda表达式

lambda表达式方便“即兴”订阅:

var button = new Button();
button.Click += (s, e) => Console.WriteLine("按钮被点击");

但如果lambda捕获了变量,就会形成“闭包”,可能增加内存占用。在UI场景下一般无妨,但在底层代码中要注意闭包的数量和生命周期。

有趣的事实:
如果连续添加两个相同的lambda,它们实际上是两个不同的delegate对象,方法会被调用两次。

事件和delegate的性能分析

当应用变大变复杂时,也要像分析其他代码一样分析事件和delegate的性能。

如何测量事件的耗时?

  • 用 Stopwatch 测量事件触发到处理完毕的时间。
  • 用内存分析工具(如 dotMemory 或 Visual Studio内置工具)找出未取消订阅、长时间存活的订阅者。
  • 查找“僵尸订阅者”——长链 invocation list 的长时间存活对象。

优化和陷阱总结表

问题/场景 解决方案
大量长时间存活(且无用)的事件 使用 EventHandlerList
订阅者拖慢全部 将繁重逻辑放到任务或单独线程
线程安全 在调用前复制delegate,添加/移除时用lock
处理器中的异常 用try/catch包裹每个调用
内存泄漏(“僵尸订阅者”) 及时取消订阅,使用IDisposable,进行性能分析

“事件生命周期”示意图:

+----------------+       +------------------+       +---------------------+
| 订阅者创建    |  -->  | 订阅(+=)     |  -->  | 进入 invocation  |
+----------------+       +------------------+       +---------------------+
                                |                                ^
                                |                                |
                   取消订阅(-=) |                     异常发生
                                v                                |
+----------------+       +--------------------+      +----------------------+
| 订阅者Dispo  |  -->  | 从调用列表中移除 |  --> | 不再成为“僵尸”   |
+----------------+       +--------------------+      +----------------------+

10. 如何在面试中讲解“事件管理”

如果遇到“C#中的事件为什么低效?”或“什么时候需要优化事件?”的问题,你可以这样回答:

  • 事件适合实现松耦合,但在大量订阅和繁重处理时效率较低。
  • 默认不是线程安全的。
  • 需要手动取消订阅,否则会导致内存泄漏。
  • 对于大量生产者和订阅者,可以用 EventHandlerList 和自定义访问器。
  • 深度控制很少用到,大部分场景用标准模式就够了。

下一节课,我们会讲更高级的场景和实际案例,展示这些优化在真实任务中的应用效果。

常见误区和反面典型

  • 认为.NET中的事件总是快的——它们在订阅者少、处理简单时确实快,但当订阅者多或处理繁重时就会变慢。
  • 指望GC会自动清理——不取消订阅,对象会一直存活!
  • 用事件来实现不同层之间的“远程”通信——更好的做法是用明确的设计模式(比如Mediator)。
2
任務
C# SELF, 等級 54, 課堂 2
上鎖
新增與移除訂閱者並控制數量
新增與移除訂閱者並控制數量
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION