1. 介绍
当我们写下
worker.WorkCompleted += listener.OnWorkCompleted;
实际上我们是把方法的指针添加到事件的“调用列表”(multicast delegate)。事件内部的这个“列表”只是将要在事件触发时被调用的一系列方法。C# 的事件是基于支持多订阅者的 delegate 实现的。
把它想象成邮件群发:你有一份订阅者名单(email 地址)。当你发送群发(触发事件)时,所有订阅者都会收到邮件。如果有人退订,会从名单里被移除,就不会再收到邮件了。
如何添加或移除订阅者
订阅(+=)和退订(-=)是对事件内部的 delegate 操作。下面是一个既能订阅也能退订的 lambda 示例:
EventHandler<WorkCompletedEventArgs> handler = (sender, e) =>
{
Console.WriteLine($"[Lambda] 工作完成:{e.Message}");
};
worker.WorkCompleted += handler; // 订阅
worker.WorkCompleted -= handler; // 退订
对于普通方法,退订看起来也是一样:
worker.WorkCompleted += listener.OnWorkCompleted;
worker.WorkCompleted -= listener.OnWorkCompleted;
注意:如果你多次订阅同一个方法,它会被调用多次;要把它完全移除,需要同样调用多次 -=。
2. 为什么要手动管理订阅?
为什么要管理订阅者?
在真实应用里,尤其是长生命周期的应用(比如桌面或服务器),不正确的订阅管理会导致内存泄漏。如果订阅者对象不再需要,但仍然“挂”在事件的订阅列表中,垃圾回收器不会回收它 —— 因为事件的 delegate 里还持有对它的引用。
示意说明
| 操作 | 对订阅者的结果 |
|---|---|
| +=(订阅) | 被添加到列表 |
| -=(退订) | 从列表移除 |
| 订阅对象被销毁 | 如果 没有 退订! — 不会被移除,因为事件里还有引用 |
| 订阅对象被销毁 | 如果 已退订 — 会被正常回收 |
如何知道谁订阅了事件?
事件通常会封装订阅者列表,所以在发布者类外部你无法直接拿到这个列表 —— 你只能添加(+=)或移除(-=)处理器。
但在声明该事件的类内部(基于 delegate,比如 EventHandler),你可以使用 GetInvocationList() 来获取当前的订阅者列表:
// 在发布者类内部
if (WorkCompleted != null)
{
foreach (Delegate subscriber in WorkCompleted.GetInvocationList())
{
Console.WriteLine($"处理器:{subscriber.Method.Name}, 对象:{subscriber.Target}");
}
}
这种方式在日常开发中不常用,但在调试或实现批量退订时会很有用。
3. 安全触发事件:“地雷”以及如何避免
触发事件时会出什么问题?
看起来很简单,你触发:
WorkCompleted?.Invoke(this, args);
多数情况下都没问题……但有些细节。如下:
1. 多线程危险
在多线程应用中可能出现这样的情况:在检查事件是否为 null 和实际调用处理器之间,另一个线程改变了订阅。比如:
1) 线程 A 检查:WorkCompleted != null。
2) 同时线程 B 从事件退订(-=),使得处理器列表变空。
3) 线程 A 尝试调用 WorkCompleted.Invoke(...) —— 会抛出 NullReferenceException,因为此时已经没有处理器了。
这是事件使用中的经典竞态条件。
2. 处理器里抛出的意外异常
如果某个订阅者在处理事件时抛出异常,后续的处理器将不会被调用。也就是说事件会在第一个异常处“中断”,其他订阅者得不到通知。为避免这种情况,如果你希望所有订阅者都能收到通知,建议对每个处理器单独用 try-catch 包裹。
3. 不期望的引用泄漏
事件处理器通常是实例方法,会捕获对订阅者对象的引用(this)。如果订阅者忘记从发布者退订,发布者的 delegate 列表会保留对它的引用。结果垃圾回收器无法回收该对象 —— 导致内存泄漏。
如何安全地触发事件?
1) 把 delegate 复制到本地变量
通过本地变量调用可以保证在调用期间订阅列表不会被修改:
// 传统做法
var handler = WorkCompleted;
if (handler != null)
{
handler(this, args);
}
或者更现代的写法,用空条件操作符:
WorkCompleted?.Invoke(this, args);
在多数情况下这就够了,因为 C# 编译器会识别这种模式并在内部做复制(参见 官方文档)。
2) 防止处理器异常中断
如果你需要确保所有处理器都被调用(即使某个失败),可以手动遍历:
var handler = WorkCompleted;
if (handler != null)
{
foreach (EventHandler<WorkCompletedEventArgs> subscriber in handler.GetInvocationList())
{
try
{
subscriber(this, args);
}
catch (Exception ex)
{
// 记录日志,但不让整个事件“崩溃”
Console.WriteLine($"处理器出错:{ex.Message}");
}
}
}
这种方式在普通 UI 场景下不常用,但在库、日志系统和复杂系统中很有用。
3) 防止内存泄漏
如果订阅者的生命周期比发布者短(例如窗口订阅了应用级事件),那么订阅者必须退订:
worker.WorkCompleted -= listener.OnWorkCompleted;
否则垃圾回收器即使没有其他“显式”引用,也无法回收 listener。
4. 实战示例:批量订阅与退订管理器
扩展一下我们的示例程序。假设有多个监听者,我们希望在程序运行时动态订阅和取消订阅它们。
public class WorkListener
{
private readonly string _name;
public WorkListener(string name)
{
_name = name;
}
public void OnWorkCompleted(object sender, WorkCompletedEventArgs e)
{
Console.WriteLine($"Listener {_name}: {e.Message}");
}
}
在主程序里:
var worker = new Worker();
var listeners = new List<WorkListener>
{
new WorkListener("Ivan"),
new WorkListener("Maria"),
new WorkListener("Denis")
};
// 给所有监听者订阅
foreach (var listener in listeners)
worker.WorkCompleted += listener.OnWorkCompleted;
// 触发事件
worker.DoWork();
// 批量退订
foreach (var listener in listeners)
worker.WorkCompleted -= listener.OnWorkCompleted;
// 确认没人再响应
worker.DoWork();
第一次工作后控制台会出现 3 条消息,第二次则不会有任何输出。
5. 与事件安全相关的建议
- 如果订阅者的生命周期短于发布者,要及时退订。
- 如果你实现的是“长生命周期发布者 — 短生命周期订阅者”的模式,务必在合适地方退订,例如在 Dispose()、窗口关闭时或对象显式结束生命周期时进行退订。
- 对于一次性事件,可以使用匿名 lambda 处理器并在内部立即退订:
EventHandler<WorkCompletedEventArgs> handler = null;
handler = (s, e) =>
{
Console.WriteLine("事件只处理一次!");
worker.WorkCompleted -= handler;
};
worker.WorkCompleted += handler;
- 不要为了检查“谁订阅了”而保留对订阅者或处理器的引用 —— 在普通业务逻辑里不需要这么做。仅在调试时才考虑使用。
6. 常见错误及如何避免
错误 1:忘记退订 —— 导致内存泄漏。
如果订阅者没有退订,尤其是在有大量事件和订阅者的大型应用里,对象可能会比预期更长时间驻留在内存中。这类错误往往不会立即显现,但会导致内存使用增长并影响性能。
错误 2:在没有检查 null 的情况下直接调用事件。
如果事件没有订阅者但直接调用,会抛出 NullReferenceException。在新版 C# 中可以用安全调用操作符 ?.,但在老代码或手动遍历处理器时,别忘了检查 null。
错误 3:某个处理器抛异常导致后续处理器未被调用。
如果某个处理器抛出异常,后面的处理器不会被执行。如果必须确保所有订阅者都收到通知,就要遍历处理器并对每个调用使用 try/catch。
GO TO FULL VERSION