C#取消事件挂钩后仍然被挂钩

7
我目前正在调试一个非常大的C#应用程序,其中包含内存泄漏问题。它主要使用Winforms作为GUI,但是有几个控件是在WPF中制作的,并通过ElementHost进行托管。到目前为止,我发现许多内存泄漏都是由于事件没有被取消注册(通过调用-=)引起的,我已经能够解决这个问题。
然而,我刚刚遇到了类似的问题。有一个名为WorkItem(短寿命)的类,在构造函数中向另一个名为ClientEntityCache(长寿命)的类的事件注册。这些事件从未被取消注册,我可以在.NET分析器中看到WorkItem实例因为这些事件而被保持活动状态,而它们本不应该保持活动状态。因此,我决定让WorkItem实现IDisposable接口,并在Dispose()函数中以以下方式取消注册事件:
public void Dispose()
{
  ClientEntityCache.EntityCacheCleared -= ClientEntityCache_CacheCleared;
  // Same thing for 10 other events
}

编辑

这是我用于订阅的代码:

public WorkItem()
{
  ClientEntityCache.EntityCacheCleared += ClientEntityCache_CacheCleared;
  // Same thing for 10 other events
}

我还改变了取消注册的代码,不再调用新的EntityCacheClearedEventHandler。

编辑结束

我在使用WorkItem的代码中,在合适的位置做了Dispose的调用,并且在调试时可以看到函数确实被调用了,并且我对每个事件都进行了减法处理。但是仍然存在内存泄漏,我的WorkItems在被Dispose之后仍然保持活动状态,在.NET分析器中可以看到实例仍然存在,因为事件处理程序(如EntityCacheClearedEventHandler)仍然有它们在调用列表中。我尝试多次解除绑定它们(多个-=),以确保它们没有被绑定多次,但这并没有帮助。

任何人有任何想法为什么会发生这种情况或者我该如何解决这个问题吗? 我想我可以改变事件处理程序使用弱委托,但这需要大量修改遗留代码。

谢谢!

编辑:

如果有帮助的话,这里是.NET分析器描述的根路径: 很多东西都指向ClientEntityCache,这个指向EntityCacheClearedEventHandler,这个指向Object [],这个指向另一个实例的EntityCacheClearedEventHandler(我不知道为什么),这个指向WorkItem。


Dispose 方法是否被不同线程调用? 您是否为事件使用自定义添加/删除?尽管委托状态是不可变的(因此不会被破坏),但如果您实现了自定义添加/删除并且没有包括默认实现锁定,则多个添加或删除操作可能会相互干扰。 - Dan Bryant
你确定没有别的东西正在引用 WorkItems 吗?我不确定实现 IDisposable 是否是最好的方法,你尝试释放资源的方式并非非托管且应在下一个垃圾回收周期中被释放。你是否尝试过只将委托设置为 null 而不是尝试删除事件处理程序?事件上关联了多个处理程序吗? - Marcus King
@Marcus King:是的,我确定没有其他东西在引用WorkItems。我可以在.NET分析器中看到唯一引用它们的是事件处理程序。对于IDisposable,我同意这可能不是最好的解决方案,但我没有方法调用来告诉WorkItem取消注册,所以我尝试了这个。也许我应该只是创建一个名为CleanUp或类似的方法。 - Carl
如果您正在调用对象的dispose方法,那么似乎您的意图是销毁整个对象,为什么不只是将对象设置为null并等待垃圾回收或调用GC.Collect()和GC.WaitForPendingFinalizers()呢? - Marcus King
@Marcus King - 永远不要调用GC.Collect。那是一个巨大的代码异味。 - vcsjones
显示剩余4条评论
5个回答

4

可能有多个不同的委托函数被连接到事件上。希望下面的小例子能更清楚地说明我的意思。

// Simple class to host the Event
class Test
{
  public event EventHandler MyEvent;
}

// Two different methods which will be wired to the Event
static void MyEventHandler1(object sender, EventArgs e)
{
  throw new NotImplementedException();
}

static void MyEventHandler2(object sender, EventArgs e)
{
  throw new NotImplementedException();
}


[STAThread]
static void Main(string[] args)
{
  Test t = new Test();
  t.MyEvent += new EventHandler(MyEventHandler1);
  t.MyEvent += new EventHandler(MyEventHandler2); 

  // Break here before removing the event handler and inspect t.MyEvent

  t.MyEvent -= new EventHandler(MyEventHandler1);      
  t.MyEvent -= new EventHandler(MyEventHandler1);  // Note this is again MyEventHandler1    
}

如果在事件处理程序被移除之前中断,您可以在调试器中查看调用列表。如下图所示,有两个处理程序,一个是MyEventHandler1,另一个是MyEventHandler2方法。
现在,在多次删除MyEventHandler1后,MyEventHandler2仍然注册,因为只剩下一个委托,它看起来有点不同,它不再显示在列表中,但直到MyEventHandler2的委托被删除,它仍将被事件引用。

你是说他需要解除所有处理程序才能释放资源吗? - Marcus King
谢谢提供这些好例子,但不幸的是它们不适用于我的情况。取消事件注册后,调用列表将变为 null。所以我想一切似乎都没问题,但我仍然不明白为什么 .NET 分析工具告诉我 WorkItem 仍被 EntityCacheClearedEventHandler 的引用所保留。 - Carl
如果目标对象仍然被保持活动状态,因为事件仍然引用目标对象中的处理程序,那么是的,他需要删除所有处理程序,以便事件不再持有对目标对象的引用,从而释放目标对象。当然,我们无法看到所有的代码,这就是为什么我试图为@Carl提供调查问题这个方面的工具。 - Chris Taylor
1
@Carl,这里要小心。如果只有一个委托,则_invocationList为null,因此可能会产生误导性。请参见第二个截图,我没有显示_invocationList,因为它为null,但是Event不为null,它仍然引用MyEventHandler2,并将一直保持这样,直到您说t.MyEvent -= new EventHandler(MyEventHandler2)。当然,这是假设这实际上是您面临的问题。 - Chris Taylor
当我调试时,似乎可以看到事件处理程序没有保留对我的WorkItems的引用。我不明白为什么.NET分析器告诉我有一个引用,但如果我相信调试器,似乎我的问题应该得到解决。 - Carl
显示剩余5条评论

2

解除事件时,需要使用相同的委托。像这样:

public class Foo
{
     private MyDelegate Foo = ClientEntityCache_CacheCleared;
     public void WorkItem()
     {
         ClientEntityCache.EntityCacheCleared += Foo;
     }

     public void Dispose()
     {
         ClientEntityCache.EntityCacheCleared -= Foo;
     }
}

原因是,您使用的是这个的语法糖:

public class Foo
{
     public void WorkItem()
     {
         ClientEntityCache.EntityCacheCleared +=
new MyDelegate(ClientEntityCache_CacheCleared);
     }

     public void Dispose()
     {
         ClientEntityCache.EntityCacheCleared -=
new MyDelegate(ClientEntityCache_CacheCleared);
     }
}

所以-=不会取消你订阅的原始委托,因为它们是不同的委托。

@vcjones,这不是问题,它不需要是相同的委托实例。它只需要是原始委托包含的相同方法。 - Chris Taylor

0

或许可以尝试:

 public void Dispose()
    {
      ClientEntityCache.EntityCacheCleared -= ClientEntityCache_CacheCleared;
      // Same thing for 10 other events
    }

您正在创建一个新的事件处理程序并将其从delegate中删除 - 这实际上什么也不做。

通过删除对原始订阅事件方法的引用来删除事件订阅。

您可以始终设置eventhandler = delegate {};在我看来,这比null更好。


实际上,编译器会隐式添加一个 new EntityCacheClearedEventHandler(ClientEntityCache_CacheCleared) - ChaosPandion
我刚刚尝试了通过执行ClientEntityCache.EntityCacheCleared += ClientEntityCache_CacheCleared;进行注册,通过执行ClientEntityCache.EntityCacheCleared -= ClientEntityCache_CacheCleared;进行注销,而不是调用new EntityCacheClearedEventHandler,但我遇到了同样的问题:(。 - Carl
@Chaos:有链接吗?我想要研究一下。这样做没有意义。 - IAbstract
1
@IAbstract,使用ILDASM,您将看到编译器为任何语法生成相同的IL。如果未明确指定,则编译器会发出构造委托实例的代码,因此无论使用哪种C#语法,最终结果都是ClientEntityCache.EntityCacheCleared += new MyDelegate(ClientEntityCache_CacheCleared); - Chris Taylor
@IAbstract,没问题。这是一个常见的误解,老实说,委托和事件在这方面呈现出一种非直观的方式,人们肯定会期望你应该删除相同的引用,即使null的处理方式也不是很直观,经常成为讨论的话题。 - Chris Taylor

0
你是否在取消挂钩正确的引用? 当使用-=取消挂钩时不会产生错误,如果你取消挂钩未挂钩的事件,则什么都不会发生。然而,如果使用+=添加,如果事件已经被挂钩,你将会收到一个错误。现在,这只是一种诊断问题的方法,但尝试添加事件,如果你没有收到错误,那么问题就是你正在使用错误的引用来取消挂钩事件。

我刚刚尝试在调用 -= 的地方使用 +=,即使调试器通过了那段代码,我也没有收到任何错误。这是否意味着我正在尝试取消注册错误的事件或者...? - Carl
是的,你正在失去对引用的追踪,这就是为什么它不能按照你的期望工作的原因。每个委托都是唯一的,你需要使用相同的委托进行注册和注销。 - John Leidegren

0

如果实例被事件处理程序保持活动状态,Dispose 方法将不会被 GC 调用,因为它仍然被事件源引用。

如果您自己调用了 Dispose 方法,则引用将超出范围。


当我完成对WorkItems的操作后,我会自己调用Dispose方法。我可以在调试时看到它被调用。但是引用仍然被保留。 - Carl
调用Dispose不会导致引用超出范围。Dispose可能会清除实例的内部状态,但这不会影响实例的作用域。 - Chris Taylor
1
我想说的是,如果对象被事件监听器保持活动状态,则该对象的Dispose方法不会被框架调用。因此,在此方法中取消事件处理程序的绑定,并期望GC调用它是行不通的。 - Noel Kennedy
如果您自己调用它,请取消引用,而对象仍然存在,则必须有另一个对象持有对它的引用。 - Noel Kennedy

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