C# still hooked to an event after unhooking
我目前正在调试一个包含内存泄漏的大(非常大!)C# 应用程序。它主要使用 Winforms 作为 GUI,尽管有几个控件是在 WPF 中制作并由 ElementHost 托管。到目前为止,我发现许多内存泄漏是由于事件没有被取消挂钩(通过调用 -=)引起的,我已经能够解决问题。
但是,我刚刚遇到了类似的问题。有一个名为 WorkItem(短期)的类,它在构造函数中注册到另一个名为 ClientEntityCache(长期)的类的事件。这些事件从未解开,我可以在 .NET 分析器中看到,由于这些事件,WorkItem 的实例在它们不应该保持活动状态时保持活动状态。所以我决定让 WorkItem 实现 IDisposable 并在 Dispose() 函数中以这种方式解开事件:
1 2 3 4 5
| public void Dispose()
{
ClientEntityCache.EntityCacheCleared -= ClientEntityCache_CacheCleared;
// Same thing for 10 other events
} |
编辑
这是我用于订阅的代码:
1 2 3 4 5
| public WorkItem()
{
ClientEntityCache.EntityCacheCleared += ClientEntityCache_CacheCleared;
// Same thing for 10 other events
} |
我还将取消注册的代码更改为不调用新的 EntityCacheClearedEventHandler。
编辑结束
我在使用 WorkItem 的代码中的适当位置调用了 Dispose,当我调试时,我可以看到该函数确实被调用了,并且我对每个事件都执行了 -=。但是我仍然会遇到内存泄漏,并且我的 WorkItems 在 Disposed 后仍然保持活动状态,并且在 .NET 分析器中我可以看到实例保持活动状态,因为事件处理程序(如 EntityCacheClearedEventHandler)仍然将它们放在调用列表中。我试图不止一次地解开它们(多个-=),只是为了确保它们没有被多次钩住,但这没有帮助。
有人知道为什么会发生这种情况,或者我可以做些什么来解决这个问题吗?
我想我可以更改事件处理程序以使用弱委托,但这需要用一大堆遗留代码搞砸很多。
谢谢!
编辑:
如果这有帮助,这里是 .NET profiler 描述的根路径:
很多东西都指向ClientEntityCache,它指向EntityCacheClearedEventHandler,它指向Object[],它指向另一个EntityCacheClearedEventHandler实例(我不明白为什么),它指向WorkItem。
- 你能告诉我们活动订阅代码吗?
-
Dispose 是从不同的线程调用的吗?您是否为事件使用自定义添加/删除?委托状态不能被破坏(因为它是不可变的),但是如果您实现自定义添加/删除并且不包括默认实现锁定,则多个添加或删除操作可能会相互干扰。
-
您可能希望在调试器中停下来查看调用列表 (Delegate.GetInvocationList) 并查看列出了多少事件处理程序。可能存在未绑定到 ClientEntityCache_CacheCleared 而是绑定到其他一些函数的事件处理程序。
-
您确定没有其他东西在引用 WorkItems 吗?我不确定实施 IDisposable 是否是最好的方法,您尝试处理的资源不是不受管理的,应该由 GC 在下一个周期释放。您是否尝试过将委托设置为 null 而不是尝试删除事件处理程序?是否有多个与事件关联的处理程序?
-
@Marcus King:是的,我确定没有其他东西在引用 WorkItems。我可以在 .NET 分析器中看到唯一引用它们的是事件处理程序。对于 IDisposable,我同意这可能不是最好的解决方案,但我没有方法可以调用来告诉 WorkItem 取消注册,所以我尝试了。也许我应该创建一个名为 CleanUp 或类似的方法。
-
如果您在对象上调用 dispose,那么您的意图似乎是破坏整个事物,您不能将对象设置为 null 并等待垃圾收集或调用 GC.Collect() 和 GC.WaitForPendingFinalizers()
-
@Marcus King - 永远不要打电话给 GC.Collect。那是一种巨大的代码气味。
-
@vcsjones 我也不建议调用它,但听起来如果他真的担心内存被使用并且想要对何时释放对象进行细粒度控制,那么真的没有任何其他选择。它有潜在的危险,但它可能对他有用。你不能说永远不要使用它。 MS 将其放入框架中,以便在正确的情况下使用它。这是否是有待商榷
-
@Marcus King:将对象设置为 null 已经在代码中完成,在调用我的 Dispose 函数后我仍然这样做。但是,如果我运行我的应用程序一个小时左右,当我真的一次只需要 1 或 2 个时,我会获得 100 个工作项。因此,我检查了问题所在,.NET 探查器告诉我,即使设置为 null,由于来自 ClientEntityCache 的事件,我似乎无法从中注销,所以它们都保持活动状态。我不调用 GC.collect() 并且我不想这样做,因为我不在乎 WorkItems 何时被收集,我只是希望它们在某个时候被收集。
可能是多个不同的委托函数连接到事件。希望下面的小例子能让我更清楚我的意思。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| // 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
} |
如果您在删除事件处理程序之前中断,您可以在调试器中查看调用列表。见下文,有 2 个处理程序,一个用于 MyEventHandler1,另一个用于方法 MyEventHandler2.
现在删除 MyEventHandler1 两次后,MyEventHandler2 仍然被注册,因为只剩下一个委托它看起来有点不同,它不再显示在列表中,但是直到 MyEventHandler2 的委托被删除它仍然会被引用由事件。
-
您是说他需要解开所有处理程序以释放资源吗?
-
感谢您提供了很好的示例,但不幸的是,情况并非如此。取消注册到事件后,invocationList 变为空。所以我想一切似乎都很好,但我仍然不明白为什么 .NET 分析器告诉我 WorkItem 由于来自 EntityCacheClearedEventHandler 的引用而保持活动状态。
-
好吧,如果目标对象因为事件仍在引用目标对象中的处理程序而保持活动状态,那么是的,他将需要删除所有处理程序,以便事件不再持有对目标对象的引用,并且目标对象可以被发布。当然,我们没有看到所有代码,这就是为什么我试图为@Carl 提供工具来调查问题的这方面。
-
@Carl,在这里要小心。如果只有一个委托,则 _invocationList 为空,因此可能会产生误导。看第二个屏幕截图,我没有显示 _invocationList 因为它是空的,但事件不是空的,它仍然引用 MyEventHandler2 并且会这样做,直到你说 t.MyEvent -= new EventHandler(MyEventHandler2)。当然,这是假设这实际上是您面临的问题。
-
哦,是的,你是对的。我刚刚看到该事件仍在引用另一个类ActivitiesWorkItem(派生自WorkItem)的方法。我检查了,有在 ClientEntityCache 的事件上注册的 ActivitiesWorkItem 实例和 WorkItem 实例。我在 WorkItem 中正确注销了它们,但我还没有为ActivitiesWorkItem 完成它。我的 WorkItem 实例是否有可能因为 ActivitiesWorkItem 实例未从事件中注销而保持活动状态?我得说我很难理解创建那个应用程序的人的设计!
-
@Carl,如果没有看到更多代码,这非常困难。但基本上,我相信您知道这一点,该事件将包含对所有具有附加到事件的处理程序方法的对象实例的引用。因此,在这些对象从他们订阅的事件中删除之前,这些对象将没有资格被收集。当然,如果三个对象每个都有一个订阅,并且只有一个对象需要被回收,那么只有该对象需要取消订阅,以便不再持有对该对象的引用。因此,您需要识别所有参考依赖项...
-
...确定是什么使对象保持活力。如果是我遇到了这个问题,我会使用 WinDBG 进行快速内存转储,然后使用 !gcroot 命令查找仍然保持对目标对象的所有引用。但这是相当先进的,将是最后的手段。
-
好的非常感谢您的帮助!我会试着用 WinDBG 看看,但我从来没有用过它。
-
还有一点我不明白。我之前遇到过这个事件处理程序问题几次,.NET 内存分析器的结果是 MyEventHandler 将其字段 _target 设置为 MyInstance ,因此 MyInstance 保持活动状态。我现在得到的是 MyEventHandler,它的 _invocationList 指向一个 Object 数组,其中一个 Object 是另一个 MyEventHandler,它的字段 _target 指向 MyInstance。 (我不确定我应该把它作为评论,但我还是 StackOverflow 的新手,我不知道还能把它放在哪里,哈哈)。
-
似乎当我调试时,我可以看到事件处理程序不保留对我的 WorkItems 的引用。我不明白为什么 .NET 分析器告诉我有一个,但如果我信任调试器,我的问题似乎应该得到解决。
取消挂钩事件时,它必须是同一个委托。像这样:
1 2 3 4 5 6 7 8 9 10 11 12 13
| public class Foo
{
private MyDelegate Foo = ClientEntityCache_CacheCleared;
public void WorkItem()
{
ClientEntityCache.EntityCacheCleared += Foo;
}
public void Dispose()
{
ClientEntityCache.EntityCacheCleared -= Foo;
}
} |
原因是,您使用的是语法糖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public class Foo
{
public void WorkItem ()
{
ClientEntityCache .EntityCacheCleared +=
new MyDelegate (ClientEntityCache_CacheCleared );
}
public void Dispose ()
{
ClientEntityCache .EntityCacheCleared -=
new MyDelegate (ClientEntityCache_CacheCleared );
}
} |
所以 -= 不会取消与您订阅的原始用户的挂钩,因为它们是不同的代表。
-
@vcjones,情况并非如此,它不需要是同一个委托实例。它只需要与原始委托包含的方法相同。
如果实例被事件处理程序保持活动状态,则 GC 不会调用 Dispose,因为它仍然被事件源引用。
如果您自己调用 Dispose 方法,则引用将超出范围。
-
当我完成 WorkItems 后,我自己调用我的 Dispose 方法。我可以看到它在调试时被调用。仍然保留参考。
-
调用 Dispose 不会导致引用超出范围。 Dispose 可能会清除实例的内部状态,但这不会影响实例的范围。
-
我想我想说的是,如果对象被事件侦听器保持活动状态,则该对象将不会调用其 Dispose 方法(由框架调用)。因此,在此方法中取消连接事件处理程序并期望 GC 调用它是行不通的。
-
如果您自己调用它,取消引用您的引用,并且对象仍然存在,则必须有另一个对象持有对它的引用。
您是否取消了正确的参考?当您使用 -= 取消挂钩时,不会产生错误,并且如果您取消未挂钩的事件,则不会发生任何事情。但是,如果您使用 += 添加,并且该事件已被挂钩,则会收到错误消息。现在,这只是您诊断问题的一种方法,但请尝试添加事件,如果您没有收到错误,则问题是您使用错误的引用取消了事件。
-
我只是尝试在我调用 -= 的地方执行 = ,即使调试器通过该代码,我也没有收到任何错误。那么这是否意味着我试图取消注册到错误的事件或......?
-
是的,您忘记了您的参考资料,这就是为什么它不能按您预期的那样工作。每个代表都是唯一的,您需要使用同一个代表进行注册和注销。
也许可以试试:
1 2 3 4 5
| public void Dispose()
{
ClientEntityCache.EntityCacheCleared -= ClientEntityCache_CacheCleared;
// Same thing for 10 other events
} |
您正在创建一个新的事件处理程序并将其从 delegate 中删除 - 这实际上什么都不做。
通过移除对原始订阅事件方法的引用来移除事件订阅。
你总是可以设置你的 eventhandler = delegate {}; 在我看来,这会比 null 更好。
- 实际上编译器会隐式添加一个new EntityCacheClearedEventHandler(ClientEntityCache_CacheCleare d)。
-
我刚刚尝试通过执行 ClientEntityCache.EntityCacheCleared = ClientEntityCache_CacheCleared; 进行注册并通过执行 ClientEntityCache.EntityCacheCleared -= ClientEntityCache_CacheCleared 取消注册;而不是调用 new EntityCacheClearedEventHandler 但我遇到了同样的问题:(。
-
@Chaos:有链接吗?我想调查一下……这样做没有意义……
-
@IAbstract,使用ILDASM,您将看到编译器为任一语法生成相同的IL。如果未明确指定,编译器会发出代码来构造委托实例,因此无论使用哪种 C# 语法,最终结果都是 ClientEntityCache.EntityCacheCleared += new MyDelegate(ClientEntityCache_CacheCleared);
-
@IAbstract,没问题。这是一个常见的误解,老实说,代表和因此在这方面以非直观方式呈现的事件,人们肯定会期望您应该删除相同的引用,即使处理 null 的方式也不是很直观并且经常成为讨论的话题。