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)。
GO TO FULL VERSION