1. 介绍
在某种意义上,C# 中订阅事件就像向好友订阅梗图推送:只要你不说“够了”并取消订阅,消息就会一直来。在编程里这尤其重要,因为忘记取消订阅不只是“又来一张梗图”,而是会造成内存泄漏!
想象一下,你程序里有一个表单(比如额外的设置窗口)。它订阅了主窗口的事件以响应变化。用户关闭了表单,以为它被销毁了,但处理器仍然订阅着!因为发布者通过事件持有对它的引用,表单仍然存活在内存中。
结论: 如果订阅者对象订阅了发布者的事件并“忘记”取消订阅,只要发布者还活着,垃圾回收器就不会回收订阅者。
回顾一下操作符 += 并演示 -=
- += — 订阅:把处理器加入事件调用列表。
- -= — 取消订阅:从事件调用列表移除处理器。
看起来大概是这样:
worker.WorkCompleted += handler; // 订阅
worker.WorkCompleted -= handler; // 取消订阅
如果处理器被添加了两次,就需要删除两次,才能确保它从调用列表里真正消失。
一点内部实现细节
在背后,C# 的事件其实是个 delegate 字段(或者说 delegate 的列表),操作符 += 实际上调用了 Delegate.Combine,而 -= 调用了 Delegate.Remove。订阅事件的对象会成为引用图的一部分。这就是为什么忘记取消订阅会导致内存泄漏的原因。
2. 通过事件引起的内存泄漏:它是怎样工作的
经典情形
class Window
{
public event EventHandler Updated;
public void SimulateUpdate()
{
// 模拟:通知所有订阅者
Updated?.Invoke(this, EventArgs.Empty);
}
}
class SettingsForm
{
public void OnWindowUpdated(object sender, EventArgs e)
{
Console.WriteLine("SettingsForm 响应窗口更新");
}
}
逐步来看:
var window = new Window();
var settingsForm = new SettingsForm();
window.Updated += settingsForm.OnWindowUpdated;
window.SimulateUpdate(); // SettingsForm 响应
// 用户关闭了表单。我们丢掉了对它的所有引用:
settingsForm = null;
// 但只要 window 还活着,SettingsForm 对象就不会被回收,
// 因为 window.Updated 仍然持有对 OnWindowUpdated 方法的引用,
// 也就间接持有对 SettingsForm 实例的引用。
怎么做?
取消订阅:
// 为此我们需要保留对处理器或对象的引用:
window.Updated -= settingsForm.OnWindowUpdated;
settingsForm = null; // 现在对象可以被回收
表格:谁持有谁的引用
| 操作 | 谁持有引用 | 能否释放内存? |
|---|---|---|
| 订阅事件 (+=) | 发布者 指向 订阅者 | 不能,只要发布者活着 |
| 取消订阅 (-=) | 没有 | 可以,在删除所有外部引用后 |
| 没有订阅 | 没有 | 可以 |
3. 如何正确组织取消订阅
显式移除处理器
比如在窗口或表单关闭时可以这样做:
class SettingsForm
{
private readonly Window _window;
public SettingsForm(Window window)
{
_window = window;
_window.Updated += OnWindowUpdated;
}
public void Close()
{
_window.Updated -= OnWindowUpdated; // 取消订阅!
// 这里放关闭逻辑(比如 Dispose、GC.SuppressFinalize 等)
}
public void OnWindowUpdated(object sender, EventArgs e)
{
// 事件处理
}
}
如果 SettingsForm 是通过“关闭”按钮销毁的,重要的是别忘了调用包含取消订阅逻辑的方法(比如 Close())。
使用接口 IDisposable
对于那些订阅事件并且需要管理生命周期的复杂对象,实现 IDisposable 很方便。在 Dispose() 方法里做所有必要的取消订阅。
class SettingsForm : IDisposable
{
private readonly Window _window;
public SettingsForm(Window window)
{
_window = window;
_window.Updated += OnWindowUpdated;
}
public void OnWindowUpdated(object sender, EventArgs e)
{
// ...
}
public void Dispose()
{
_window.Updated -= OnWindowUpdated;
// 在这里释放其他资源
}
}
现在可以在 using 语句块中使用 SettingsForm,或者显式调用 Dispose(),或者在需要的地方结合 GC.SuppressFinalize 做自动化释放(针对有终结器的类型)。
4. 与 lambda 表达式交互:风险与技巧
如果你用 lambda 表达式订阅事件,但没有把 lambda 保存到变量,你就无法取消订阅!
// 订阅 — 匿名 lambda
window.Updated += (s, e) => Console.WriteLine("Lambda 被调用!");
// 现在怎么取消?— 不行!
window.Updated -= (s, e) => Console.WriteLine("Lambda 被调用!"); // 这是另一个 delegate!
怎么办?
把 lambda 保存到 delegate 变量:
EventHandler handler = (s, e) => Console.WriteLine("Lambda 被调用!");
window.Updated += handler;
// ... 现在可以取消订阅了!
window.Updated -= handler;
5. 有用的细节
对象生命周期和事件的特性
另一个常见问题是两个“长寿命”对象之间通过事件形成交叉引用。比如,一个窗口订阅了另一个窗口的事件,两个对象都一直被使用且不被回收——内存会越来越多。
建议: 时刻注意谁订阅了谁以及什么时候需要取消订阅。如果订阅者和发布者的生命周期一致,那就没问题。如果订阅者可能比发布者活得更短,那就实现显式取消订阅。
通用规则:“订阅了就要取消订阅!”
- 对于长寿命的发布者(比如全局的、单例、主窗口)——订阅者一定要实现取消订阅逻辑。
- 对于临时对象(比如一次性的通知,或者订阅者比发布者活得久的情景)——可以放松一点,但仍然要注意上下文。
- 如果不想手动管理取消订阅,可以使用像 WeakEvent(弱事件)这样的方案,或者借助特定框架。
6. 处理取消订阅时的典型错误
错误的取消订阅:处理器方法必须完全相同
在取消订阅时,你必须传入和订阅时完全相同的处理器。否则取消订阅不会生效。
错误示例:
window.Updated += settingsForm.OnWindowUpdated;
// ...
window.Updated -= new SettingsForm().OnWindowUpdated; // 不会生效!这是另一个实例和另一个 delegate!
正确示例:
window.Updated -= settingsForm.OnWindowUpdated;
如果订阅时使用了匿名 lambda 且没有保存对 delegate 的引用,就无法取消订阅,因为那将是另一个 delegate 实例:
// 订阅
window.Updated += (s, e) => Console.WriteLine("Lambda!");
// 尝试取消订阅 — 不会生效!
window.Updated -= (s, e) => Console.WriteLine("Lambda!");
“被忘记”的取消订阅
经常发生的情况是开发者忘了取消订阅,尤其当订阅者比发布者活得更久,或者开发者没完全理解事件的工作机制时。结果是订阅者在内存中比预期存在更久,导致内存泄漏和性能问题。
GO TO FULL VERSION