【问题标题】:Why and How to avoid Event Handler memory leaks?为什么以及如何避免事件处理程序内存泄漏?
【发布时间】:2011-05-30 10:27:16
【问题描述】:

通过阅读 StackOverflow 上的一些问题和答案,我刚刚意识到,在 C#(或者我猜是其他 .net 语言)中使用 += 添加事件处理程序可能会导致常见的内存泄漏...

我过去多次使用过这样的事件处理程序,但从未意识到它们会导致或已经导致我的应用程序中的内存泄漏。

这是如何工作的(意思是,为什么这实际上会导致内存泄漏)?
我该如何解决这个问题?对同一个事件处理程序使用-= 是否足够?
是否有处理此类情况的通用设计模式或最佳实践?
示例:我应该如何处理具有许多不同线程的应用程序,使用许多不同的事件处理程序在 UI 上引发多个事件?

在已经构建的大型应用程序中,有什么好的简单方法可以有效地监控这一点?

【问题讨论】:

    标签: c# design-patterns memory-leaks event-handling


    【解决方案1】:

    原因很简单:在订阅事件处理程序时,事件的发布者通过事件处理程序委托持有对订阅者的引用(假设delegate 是一个实例方法)。

    如果发布者的寿命比订阅者长,那么即使没有其他对订阅者的引用,它也会让订阅者保持活动状态。

    如果您使用相等的处理程序取消订阅事件,那么是的,这将删除处理程序和可能的泄漏。但是,根据我的经验,这实际上很少会成为问题 - 因为通常我发现发布者和订阅者的生命周期大致相同。

    一个可能的原因......但根据我的经验,它被夸大了。当然,您的里程可能会有所不同……您只需要小心。

    【讨论】:

    • 从发布者方面解决此问题的一种方法是,一旦您确定不会再触发它,就将事件设置为 null。这将隐式删除所有订阅者,并且在仅在对象生命周期的某些阶段触发某些事件时非常有用。
    • Dipose 方法是将事件设置为 null 的好时机
    • @DaviFiamenghi:嗯,如果有什么东西被处理掉了,这至少表明它很快就会有资格进行垃圾收集,此时有什么订阅者并不重要。
    • 如果事件方法是静态的怎么办?那么没有实例可以坚持,对吧? -- 似乎是一个非常简单的解决方法(并且典型的事件模式无论如何都包括发送者......)。
    • @BrainSlugs83:“无论如何,典型的事件模式都包括发送者” - 是的,但这就是事件生产者。通常,事件 subscriber 实例是相关的,而发送者则不相关。所以是的,如果您可以使用静态方法订阅,这不是问题 - 但根据我的经验,这很少是一种选择。
    【解决方案2】:

    我在https://www.spicelogic.com/Blog/net-event-handler-memory-leak-16 的博客中解释了这种困惑。在这里我尽量总结一下,让大家有个清晰的思路。

    参考意思是“需要”:

    首先,你需要明白,如果对象 A 持有对对象 B 的引用,那么,这意味着对象 A 需要对象 B 才能起作用,对吧?所以,只要对象 A 还在内存中,垃圾回收器就不会回收对象 B。

    += 表示将右侧对象的引用注入左侧对象:

    混淆来自 C# += 运算符。这个算子并没有明确告诉开发者,这个算子的右边实际上是在注入一个对左边对象的引用。

    通过这样做,对象 A 认为它需要对象 B,尽管从您的角度来看,对象 A 不应该关心对象 B 是否存在。当对象 A 认为需要对象 B 时,只要对象 A 还活着,对象 A 就会保护对象 B 免受垃圾收集器的影响。但是,如果您不想为事件订阅者对象提供这种保护,那么可以说发生了内存泄漏。为了强调这一点,让我澄清一下,在 .NET 世界中,没有像典型的 C++ 非托管程序那样的内存泄漏概念。但是,正如我所说,对象 A 保护对象 B 免受垃圾回收,如果这不是您的意图,那么您可以说发生了内存泄漏,因为对象 B 不应该存在于内存中。

    您可以通过分离事件处理程序来避免此类泄漏。

    如何做决定?

    您的整个代码库中有很多事件和事件处理程序。这是否意味着,您需要在任何地方保持分离事件处理程序?答案是否定的。如果你必须这样做,你的代码库会非常冗长而丑陋。

    您可以按照一个简单的流程图来确定是否需要分离事件处理程序。

    大多数时候,您可能会发现事件订阅者对象与事件发布者对象一样重要,并且两者都应该同时存在。

    无需担心的场景示例

    例如,窗口的按钮点击事件。

    这里,事件发布者是Button,事件订阅者是MainWindow。应用该流程图,问一个问题,主窗口(事件订阅者)是否应该在按钮(事件发布者)之前死掉?显然没有。对吧?这甚至没有意义。那么,为什么还要担心分离点击事件处理程序呢?

    事件处理程序分离是必须的示例。

    我将提供一个示例,其中订阅者对象应该在发布者对象之前死亡。比如说,您的 MainWindow 发布了一个名为“SomethingHappened”的事件,并且您通过单击按钮从主窗口显示一个子窗口。子窗口订阅主窗口的那个事件。

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

    从这段代码中,我们可以清楚的了解到主窗口中有一个按钮。单击该按钮会显示一个子窗口。子窗口监听来自主窗口的事件。做完某事后,用户关闭子窗口。

    现在,根据我提供的流程图,如果您问一个问题“子窗口(事件订阅者)是否应该在事件发布者(主窗口)之前死亡?答案应该是肯定的。对吧?所以,分离事件处理程序。我通常从 Window 的 Unloaded 事件中执行此操作。

    经验法则:如果您的视图(即 WPF、WinForm、UWP、Xamarin Form 等)订阅了 ViewModel 的事件,请始终记住分离事件处理程序。因为 ViewModel 通常比视图寿命更长。所以,如果 ViewModel 没有被销毁,那么订阅该 ViewModel 事件的任何视图都将留在内存中,这是不好的。

    使用内存分析器证明概念。

    如果我们不能使用内存分析器验证这个概念,那将不会很有趣。我在这个实验中使用了 JetBrain dotMemory profiler。

    首先,我运行了 MainWindow,它显示如下:

    然后,我拍了一张记忆快照。然后我点击了按钮 3 次。出现了三个子窗口。我已关闭所有这些子窗口并单击 dotMemory 分析器中的强制 GC 按钮以确保调用垃圾收集器。然后,我拍摄了另一个内存快照并进行了比较。看哪!我们的恐惧是真实的。即使在它们关闭之后,垃圾收集器也没有收集子窗口。不仅如此,ChildWindow 对象的泄漏对象计数也显示为“3”(我单击该按钮 3 次以显示 3 个子窗口)。

    好的,那么,我分离了事件处理程序,如下所示。

    然后,我执行了相同的步骤并检查了内存分析器。这一次,哇!不再有内存泄漏。

    【讨论】:

    • 插图不错。
    【解决方案3】:

    是的,-= 就足够了,但是,要跟踪分配的每个事件可能非常困难。 (有关详细信息,请参阅 Jon 的帖子)。关于设计模式,请看weak event pattern

    【讨论】:

    • 如果我知道发布者的寿命会比订阅者长,我将订阅者设为IDisposable 并取消订阅事件。
    • 弱事件处理程序非常棒,除非你有成千上万个。这是因为它们比普通的事件处理程序占用更多的内存。我只在数量较少时使用它们。
    【解决方案4】:

    事件实际上是事件处理程序的链表

    当您对事件执行 += new EventHandler 时,此特定函数之前是否已添加为侦听器并不重要,每个 += 都会添加一次。

    当事件被引发时,它会逐项通过链表并调用添加到此列表中的所有方法(事件处理程序),这就是即使页面不再运行时仍会调用事件处理程序的原因只要他们活着(扎根),只要他们被钩住,他们就会活着。因此,它们将被调用,直到事件处理程序与 -= new EventHandler 解除挂钩。

    See Here

    MSDN HERE

    【解决方案5】:

    我可以告诉你,这可能会成为 Blazor 中的一个问题。您可以使用+= 语法让组件订阅事件,从长远来看,这将导致泄漏。

    对此(我知道)的唯一解决方案是不使用匿名方法,让组件继承自 IDisposable 并使用 Dispose() 取消订阅事件处理程序。

    【讨论】:

      猜你喜欢
      • 2012-02-12
      • 1970-01-01
      • 2011-01-13
      • 1970-01-01
      • 2010-10-20
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多