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

优化事件驱动编程

C# SELF
第 54 级, 课程 2
可用

1. 介绍

在大多数典型应用里,事件运行得很快,几乎是"免费"的——CLR (Common Language Runtime) 对它们的处理做了很多优化。但当应用变大、事件变多、订阅链变长、性能要求提高时,你会发现:连看起来很简单的事件也可能成为瓶颈。尤其是在有大量实时更新、UI(用户界面)或在 IoT 应用中处理成千上万传感器通知的系统里,这点更明显。

本讲我们会讲:

  • 事件和委托如何影响性能。
  • 会有哪些瓶颈。
  • 如何写快速的事件代码并避免影响性能的问题。

.NET 中事件的内部结构

如前所述,事件是对委托的封装。委托是一个特殊对象,包含一个方法列表(invocation list),在调用时会逐个执行。每次触发事件时,CLR 会遍历这个列表并同步调用所有方法。(只有当你手动把异步代码放进去时才会出现异步行为。)

直观示意:


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

2. 委托和事件的开销:拆解细节

存储成本

  • 每个委托都是一个完整的对象。
  • 每个处理器(方法订阅者)会产生一个额外的委托实例。
  • 订阅者越多——对象越多,内存占用越高。

在简单场景下,内存泄漏或开销几乎可以忽略。但如果有成千上万的处理器,就需要考虑优化了!

调用成本

  • 触发事件 = 遍历 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("First");
        // 慢的
        publisher.Counted += (s, e) => System.Threading.Thread.Sleep(2000);
        // 另一个
        publisher.Counted += (s, e) => Console.WriteLine("Last");

        // 计时
        var watch = System.Diagnostics.Stopwatch.StartNew();
        publisher.Increment();
        watch.Stop();
        Console.WriteLine($"所有处理程序在 {watch.ElapsedMilliseconds} 毫秒内执行完毕。");
    }
}

运行一下——你会看到明显的停顿。第一个处理器几乎瞬时,第二个造成"延迟",然后才是第三个。

实战结论

  • 不要把重的业务逻辑直接放到事件处理器里!
  • 把这类工作移到独立线程、Task 或异步处理器里会更好。

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

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


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

为避免这种情况并且不被一个"坏苹果"拖垮,使用手动遍历并为每个处理器做异常保护。

更健壮的事件触发写法


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) 事件

如果事件可能很慢——有时我们希望把处理器放到独立线程或 Task 里去运行,以免阻塞主线程。

方案 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 线程直接调用 UI 相关处理器,否则会遇到 InvalidOperationException。

总之——异步事件需要小心设计!

6. 存储与调用的优化

"空" 事件:节省内存

如果类里有很多事件,但多数很少被使用(比如 UI 组件里的大量事件),有个技巧:使用 EventHandlerList。

它是怎么工作的

.NET 控件(例如 WinForms)不会为每个事件存一个独立的委托,而是把所有事件放到一个结构里(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);
    }
}

为啥要这么做:你可以节省内存,不会为成百上千个"空"事件创建不必要的委托。

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

.NET 中的事件本身并不是线程安全的!当某个线程在订阅或取消订阅的同时,另一个线程可能在触发事件。这样可能导致委托在调用前变成 null,从而抛出 NullReferenceException。

最佳实践

  • 使用 ?. 操作符(例如 Counted?.Invoke(...)) 来防止 null。
  • 对于复杂场景——用 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 访问器进行控制与优化

在特殊情况下(比如需要记录所有订阅或限制订阅数)可以通过自定义访问器来手动实现事件:


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. 有用的细节

Lambda、闭包与性能

Lambda 表达式方便用于临时订阅:


var button = new Button();
button.Click += (s, e) => Console.WriteLine("Button clicked");

但如果 Lambda 捕获了变量——会创建一个闭包(closure),这可能增加内存消耗。大多数 UI 场景下这不是大问题,但在底层代码里应关注闭包的数量和被捕获对象的生命周期。

有趣的事实:
如果连续添加两个相同的 Lambda,它们会是两个不同的委托对象,方法会被执行两次。

事件和委托的性能分析

当应用变大、变复杂时,事件也需要像其他代码一样被剖析和剖析性能问题。

如何测量事件的速度?

  • 使用 Stopwatch 测量从触发事件到处理结束的时间。
  • 使用内存分析工具(比如 dotMemory、Visual Studio 的内置工具)来找出那些没有取消订阅而挂在内存里的订阅者。
  • 查找"僵尸订阅者"时,看看长寿对象上的 invocation list 是否很长。

“优化与陷阱”对照表

问题/场景 解决方案
大量长生命周期(且无用)的事件 使用 EventHandlerList
订阅者拖慢整个流程 把重逻辑移到 task/独立线程
线程安全问题 在调用前复制委托,添加/移除时用 lock
处理器抛异常 对每个处理器用 try/catch 捕获
“僵尸订阅者”引发内存泄漏 总是取消订阅,实现 IDisposable,做性能分析

图示:"优化后事件的生命周期"


+----------------+       +------------------+       +---------------------+
| 订阅者被创建   |  -->  | 订阅 (+=)        |  -->  | 进入 Invocation 列表 |
+----------------+       +------------------+       +---------------------+
                                |                                ^
                                |                                |
                   取消订阅 (-=) |                     抛出异常   |
                                v                                |
+----------------+       +--------------------+      +----------------------+
| 订阅者 Dispose |  -->  | 从调用列表移除    |  --> | 不再成为僵尸订阅者    |
+----------------+       +--------------------+      +----------------------+

10. 如何在面试里解释“事件管理”

如果面试官问你 "C# 的事件在哪些情况下效率低?" 或 "什么时候需要对事件做优化?" 你可以说:

  • 事件适合做 loose coupling,但在大量订阅和重处理器的场景下效率会下降。
  • 默认情况下事件不是线程安全的。
  • 如果不手动取消订阅会导致内存泄漏。
  • 对于大量生产者和订阅者,使用 EventHandlerList 和自定义访问器 add/remove 是常用方案。
  • 深度控制很少需要——大多数场景使用标准模式就够了。

下一讲我们会看更高级的场景和实战例子,让你看到这些优化在真实任务中的效果。

常见误区和反模式

  • 认为 .NET 事件总是很快——它们确实快,但当订阅者变多或有重处理器时就不是了。
  • 指望 GC 自动清理一切——如果不取消订阅,对象会一直存活!
  • 用事件做跨层的远距离连接(业务层之间)——这种场景更适合明确的模式(例如 Mediator)。
2
任务
C# SELF, 第 54 级, 课程 2
已锁定
添加和删除订阅者并控制数量
添加和删除订阅者并控制数量
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION