我应该在Dispose方法中总是断开事件处理程序吗?

57

我在使用C#进行工作,我的工作场所有一些代码标准。其中之一是我们连接的每个事件处理程序(例如KeyDown)都必须在Dispose方法中断开连接。这样做有什么好处吗?


2
如果事件是“静态”的话,这样做确实是有意义的,因为否则,处理程序引用将用于根对象并防止GC。 - Damien_The_Unbeliever
16
这似乎是一个案例,某人曾经因为错误的代码而出现过内存泄漏问题,追踪到事件处理程序泄漏造成的问题,并决定编写并执行全局代码标准,要求所有事件处理程序在“Dispose”方法中断开连接。换句话说,他们没有真正努力去理解导致特定情况下实际问题的原因,而是决定让每个人都更忙。这是一种扭曲的模仿式编程,除了老板以外没有其他人能够强制执行。 - Cody Gray
不知道在最新版本的.NET中如何处理,但在2.0和3.5中,我们实际上遇到了非静态事件订阅的问题,当我们在大量对象中使用或经常使用时。似乎GC无法找出死链接和内存泵。重申一下,没有任何科学证据支持这一点,但是一些现实世界的实践,也得到了社区其他成员的确认。 - Tigran
@CodyGray 您说得完全正确,在开始执行此标准之前,存在内存泄漏问题。 - No Idea For Name
4
除了.NET语言无法方便地支持事件取消订阅之外,还有没有其他原因使得事件取消订阅成为例行操作?假设一个控件订阅了父控件的某些事件并且没有取消订阅它们,那么该控件就假定父控件的实例数量将在其生命周期内受到限制。如果父控件定期创建和销毁子控件,则可能会导致无限制的内存泄漏。处理事件清理应该比试图识别那些没有这样做会失败的情况更容易。 - supercat
4个回答

95

除非您希望事件发布者活得比订阅者更长,否则就没有理由删除事件处理程序。

这是一个传说已久的话题。您只需要正常地思考:发行人(例如按钮)有对订阅者的引用。如果发布者和订阅者将同时有资格进行垃圾回收(这很常见)或者如果发布者将更早地有资格进行垃圾回收,那么就没有GC问题。

静态事件会导致GC问题,因为它们实际上是一个无限长寿的发布者-在可能的情况下,我会完全不建议使用静态事件。(我很少觉得它们有用。)

另一个可能存在的问题是,如果您明确要停止侦听事件,因为如果事件被触发,您的对象会出现问题(例如,它将尝试写入关闭的流)。在这种情况下,是的,您应该删除处理程序。这很可能是在您的类已经实现IDisposable时的情况。尽管不太可能,但实现IDisposable仅仅是为了删除事件处理程序是值得的。


4
在游戏编程中,我发现静态事件类或者持续存在订阅者的发布者很常见。 - frontsidebus
3
这可能更多是设计不良的问题,而不是问题本身固有的... - Jon Skeet
3
那似乎仍不是我会使用静态事件的东西... - Jon Skeet
1
@frontsidebus:静态类很少适用于此。但我认为在评论中继续下去没有什么意义。 - Jon Skeet
4
@frontsidebus - 我知道这是一个六个月前的对话,但我想在这里提一下:依赖注入。那就是你想要的。我理解为什么你认为静态类、单例等是必要的,游戏编程深陷于这种思维方式中,但有更好的方法。 - Chaser324
显示剩余3条评论

24

也许,这个标准是作为一种防止内存泄漏的防御性实践提出的。我不能说这是一个坏标准。但是,我个人更喜欢仅在需要时断开事件处理程序。这样,我的代码看起来更加清洁简洁。

我写了一篇博客,解释了事件处理程序如何导致内存泄漏以及何时断开连接; https://www.spicelogic.com/Blog/net-event-handler-memory-leak-16。在这里,我将总结说明以回答您的核心问题。

C#事件处理程序运算符实际上是一个引用注入器:

在C#中,+=运算符看起来非常无害,许多新开发人员并不明白右侧对象实际上正在将其引用传递给左侧对象。

enter image description here

事件发布者保护事件订阅者:

那么,如果一个对象引用了另一个对象,问题是什么?问题在于,当垃圾回收器来清理并找到一个重要的保留在内存中的对象时,它不会清理所有由该重要对象引用的对象。让我简单解释一下。假设您有一个名为“Customer”的对象。假设此客户对象引用了CustomerRepository对象,以便客户对象可以搜索存储库中的所有地址对象。因此,如果垃圾回收器发现需要保持客户对象处于活动状态,则垃圾回收器还将保持客户存储库处于活动状态,因为客户对象引用了客户存储库对象。这是有道理的,因为客户对象需要customeRepository对象才能正常工作。

但是,事件发布者对象需要事件处理程序来运行吗?不需要,对吧?事件发布者独立于事件订阅者。事件发布者不应关心事件订阅者是否存活。当您使用+=运算符订阅事件发布者的事件时,事件发布者接收事件订阅者的引用。垃圾回收器认为,事件发布者需要事件订阅者对象才能正常工作,因此它不会收集事件订阅者对象。

以这种方式,事件发布者对象“a”保护事件订阅者对象“b”不被垃圾回收器收集。

只要事件发布者对象处于活动状态,事件发布者对象就会保护事件订阅者对象。

enter image description here

因此,如果您分离事件处理程序,则事件发布者不会持有事件订阅者的引用,垃圾回收器可以自由地收集事件订阅者。

但是,您真的需要始终分离事件处理程序吗?答案是否定的。因为许多事件订阅者确实应该在事件发布者存在的时间内保留在内存中。

一个流程图以做出正确的决策:

enter image description here

大部分情况下,我们发现事件订阅者对象和事件发布者对象同样重要,并且两者应该同时存在。 一个无需担心的场景示例: 例如,窗口的按钮点击事件。

enter image description here

在这里,事件发布者是Button,事件订阅者是MainWindow。应用该流程图,问一个问题,Main Window(事件订阅者)应该在Button(事件发布者)之前死亡吗?显然不是。对吧?那甚至没有意义。那么,为什么要担心分离单击事件处理程序呢?
一个事件处理程序必须分离的例子:
我将提供一个例子,其中订阅者对象应该在发布者对象之前死亡。假设您的MainWindow通过单击按钮从主窗口显示一个子窗口,并发布名为“SomethingHappened”的事件。子窗口订阅主窗口的该事件。

enter image description here

而且,子窗口订阅了主窗口的一个事件。

enter image description here

当用户在MainWindow中点击按钮时,子窗口会显示出来。然后,当用户完成来自子窗口的任务时,关闭子窗口。现在,根据我提供的流程图,如果你问一个问题:“事件订阅者(子窗口)是否应该在事件发布者(主窗口)之前死亡?”答案应该是“是”。那么,在子窗口的任务完成后,确保分离事件处理程序。好的地方是ChildWindow的Unloaded事件。
验证内存泄漏概念:
我使用Jet Brains的dotMemory Memory profiler软件对这段代码进行了分析。我启动了MainWindow并点击了按钮3次,这将显示一个子窗口。因此,3个Child Window实例显示出来。然后,我关闭了所有的子窗口,并比较了子窗口出现之前和之后的快照。我发现,即使我关闭了所有子窗口,仍有3个Child Window对象存在于内存中。

enter image description here

然后,在子窗口的Unloaded事件中,我已经分离了事件处理程序,就像这样:

enter image description here

然后,我再次进行了分析,这一次,哇!不再有那个事件处理程序引起的内存泄漏。

enter image description here


11
如果我在创建和销毁用户控件时,没有在Dispose()中注销事件处理程序,那么我的应用程序将出现重大的GDI泄漏问题。我在Visual Studio 2013帮助文档——C#编程指南中发现了以下内容。请注意我已经用斜体标记的内容:

如何订阅和取消订阅事件

...省略...

取消订阅

为了防止事件被触发后调用事件处理程序,应该取消订阅事件。为了避免资源泄漏,您应该在订阅对象被处理之前取消订阅事件。只要未取消订阅事件,在发布对象中支持事件的多播委托就会引用封装订阅者的事件处理程序的委托。只要发布对象保留着该引用,垃圾回收器就不会删除您的订阅对象。

请注意,在我的情况下,发布者和订阅者都在同一个类中,并且这些处理程序不是静态的。

1

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接