您应该知道的 5 种技术,以避免 C# .NET 中的事件导致内存泄漏

转载自:https://michaelscodingspot.com/5-techniques-to-avoid-memory-leaks-by-events-in-c-net-you-should-know/


避免 C# .NET 中的内存泄漏

C#(以及一般的 .NET)中的事件注册是内存泄漏的最常见原因。至少从我的经验来看。事实上,我从事件中看到了如此多的内存泄漏,以至于 在代码中看到 +=立即让我产生怀疑。

虽然事件是伟大的,但它们也很危险。如果您不知道要查找什么,则事件很容易导致内存泄漏。在这篇文章中,我将解释这个问题的根本原因,并提供几个最佳实践技术来处理它。最后,我将向您展示一个简单的技巧,以确定您是否确实存在内存泄漏。

了解内存泄漏

在垃圾收集环境中,内存泄漏这个词有点 反直觉当有一个垃圾收集器负责收集所有内容时,我的内存怎么会泄漏?

答案是存在垃圾收集器 ( GC ) 时,内存泄漏意味着有些对象仍被引用但实际上未使用。由于它们被引用,GC 不会收集它们,它们将永远保留,占用内存。

让我们看一个例子:

在这个例子中,我们假设WiFiManager 在程序的整个生命周期中都处于活动状态。执行SomeOperation 后会创建一个MyClass实例,并且不再使用。程序员可能认为 GC 会收集它,但事实并非如此。所述WiFiManager保持在其事件MyClass的参考 WiFiSignalChanged和它引起了内存泄漏。GC 永远不会收集MyClass

1.确保退订

显而易见的解决方案(虽然并不总是最简单的)是记住从事件中取消注册您的事件处理程序。一种方法是实现 IDisposable:

当然,您必须确保调用Dispose如果您有 WPF 控件,一个简单的解决方案是在Unloaded事件中取消订阅

优点:简单,可读的代码。

缺点:您很容易忘记取消订阅,或者在所有情况下都不会取消订阅,这将导致内存泄漏。

注意:并非所有事件注册都会导致内存泄漏。当注册一个你会活得更久的事件时,不会有内存泄漏。例如,在 WPF UserControl 中,您可能会注册到 Button 的Click事件。这很好,无需注销,因为用户控件是唯一引用该按钮的控件。当没有人引用用户控件时,也不会有人引用 Button 并且GC将收集两者。

2. 有自己取消订阅的处理程序

在某些情况下,您可能希望事件处理程序只发生一次。在这种情况下,您会希望代码自行取消订阅。当您的事件处理程序是一个命名方法时,这很容易:

但是,有时您希望事件处理程序是 lambda 表达式。在这种情况下,这里有一个有用的技术可以让它自己取消订阅:

在上面的示例中,lambda 表达式很有用,因为您可以捕获局部变量someObject,而使用处理程序方法则无法做到这一点。

优点:简单,可读,只要您确定事件至少会触发一次,就不会发生内存泄漏。

缺点:仅在需要处理事件一次的特殊情况下可用。

3. 将弱事件与事件聚合器结合使用

当你在 .NET 中引用一个对象时,你基本上是告诉 GC 该对象正在使用中,所以不要收集它。有一种方法可以引用一个对象,而无需实际说“我正在使用它”。这种引用称为 弱引用相反,您是在说“我不需要它,但如果它仍然存在,那么我会使用它”。其他换句话说,如果一个对象只被弱引用引用,GC将收集它并释放该内存。这是使用 .NET 的WeakReference 类实现的。

我们可以通过多种方式使用它来防止内存泄漏。一种流行的设计模式是使用Event Aggregator这个概念是任何人都可以订阅类型为 T 的事件,任何人也可以发布类型为 T 的事件。所以当一个类发布事件时,所有订阅的事件处理程序都会被调用。事件聚合器使用 WeakReference 引用所有内容。所以即使一个对象蒂斯 订阅了一个事件,它仍然可以被垃圾收集。

这是一个使用Prism 流行的事件聚合器的示例(可与 NuGet Prism.Core 一起使用

优点: 防止内存泄漏,比较容易使用。

缺点: 充当所有事件的全局容器。任何人都可以订阅其他任何人。这使得系统在过度使用时难以理解。没有关注点分离。

4. 对常规事件使用弱事件处理程序

通过一些代码技巧,可以在常规事件中使用弱引用。这可以通过几种不同的方式实现。下面是一个使用 Paul Stovell 的WeakEventHandler的例子

我真的很喜欢这种方法,因为发布者,在我们的例子中是WiFiManager,与标准 C# 事件保持一致。这只是这种模式的一种实现,但实际上有很多方法可以实现。Daniel Grunwald写了一篇关于不同实现及其差异的广泛文章

优点:利用标准事件。简单的。没有内存泄漏。关注点分离(与事件聚合器不同)。

缺点:这种模式的不同实现有微妙之处和不同的问题。示例中的实现实际上创建了一个  从未被 GC 收集的注册包装器对象。其他实现可以解决这个问题,但还有其他问题,比如额外的样板代码。在 Daniel 的文章中查看更多信息 

WeakReference 解决方案的问题

使用WeakReference意味着GC将能够在可能的情况下收集订阅类。但是,GC 不会立即收集未引用的对象。就开发人员而言,它是随机的。因此,对于弱事件,您可能会在当时不应该存在的对象中调用事件处理程序。

事件处理程序可能会做一些无害的事情,比如更新内部状态。或者它可能会改变程序状态,直到 GC 决定收集它的某个随机时间。这种行为确实很危险。The Weak Event Pattern 中对此的补充阅读 是危险的

5. 在没有内存分析器的情况下检测内存泄漏

这种技术是为了测试现有的内存泄漏,而不是首先编码模式来避免它们。

假设您怀疑某个类存在内存泄漏。如果您有一个场景,您创建一个实例,然后期望GC收集它,您可以轻松地确定您的实例是否会被收集或是否存在内存泄漏。按着这些次序:

1.向您的可疑类添加一个终结器并在其中放置一个断点:

2.在场景开始添加这些神奇的3行

这将强制 GC 收集到目前为止所有未引用的实例(不要在生产中使用),因此它们不会干扰我们的调试。

3. 添加相同的 3 行魔术代码以 在场景之后运行请记住,该场景是您的可疑对象被创建并应该被收集的场景。

4. 运行有问题的场景。

在第 1 步中,我告诉过您在类的 Finalizer 中放置一个断点。第一次垃圾收集完成您实际上应该注意该断点否则,您可能会对正在处理的旧实例感到困惑。需要注意的重要时刻是 您的场景之后调试器是否在 Finalizer 中停止 。 

它还有助于在类的构造函数中放置一个断点。通过这种方式,您可以计算它的创建次数与最终确定的次数。如果触发了终结器中的断点,则 GC 收集了您的实例,一切正常。如果没有,那么你有内存泄漏。

这是我调试一个使用上一个技术中的 WeakEventHandler 并且没有内存泄漏的场景:

这是我使用常规事件注册的另一种情况,它确实存在内存泄漏:

概括

C# 似乎是一门容易学习的语言,它有一个提供训练轮的环境,这总是让我感到惊讶。但实际上,它远非如此。像使用事件这样的简单事情可以很容易地将您的应用程序变成一堆未受过训练的人的内存泄漏。

至于在代码中使用的正确模式,我认为这篇文章的结论应该是所有场景都没有正确和错误的答案。所有提供的技术,以及他们, 视情况而定是可行的解决方案。 

原来这是一个比较大的帖子,但我在这个问题上还是保持了一个相对较高的水平。这只是证明了这些问题存在的深度以及软件开发如何永远有趣。

有关内存泄漏的更多信息,请查看我的文章在 C# .NET 中查找、修复和避免内存泄漏:8 个最佳实践它从我自己的经验和其他为我提供建议的高级 .NET 开发人员那里获得了大量信息。它包括有关内存分析器、非托管代码的内存泄漏、监控内存等的信息。

我希望你在评论部分留下一些反馈。并且一定要订阅博客并收到新帖子的通知。

分享:

想成为解决问题的专家吗?查看我的《面向 .NET 开发人员的实用调试》一书中的一章



关于“你应该知道的在 C# .NET 中避免内存泄漏的 5 种技术”的 19 个想法

    1. Rx.Net 带有一组方便的 System.IDisposable 实现和模式。以下是一些:
      * SerialDisposable – 具有可多次分配的 Disposable 属性。每次进行新分配时,旧的 IDisposable 将被处理。当您必须对更改做出反应时,这非常方便,例如,您可能在运行时收到多个对象,而每次您都必须取消订阅最后一个对象并订阅新对象。

      * CompositeDisposable – 是一个 IDisposable 对象,它也是其他 IDisposable 对象的容器。当 Dispose 方法被调用时,它也会处理包含的 IDisposables。

      * Disposable.Create – 此方法接受一个 lambda (Action) 并将其包装在 IDisposable 实现中。

      * Disposable.Empty – 返回一个 IDisposable 对象,在调用 Dispose 时它什么也不做 – 您可以将其用作空对象模式,您可以在其中安全地调用 Dispose,而无需担心空值。我喜欢将它与 SerialDisposable 一起使用。例如,对于 Interactivity.Behaviors (WPF/UWP),在 OnAttached 中,我将为其分配一个实际的一次性对象,而在 OnDeattaching 中,我将为其分配 Disposable.Empty。

      //code
      public override void OnAttached()
      {
      //Subscribe
      _serialDisposable.Disposable = new CompositeDisposable
      {
      Observable.FromEventPattern(AssociatedObject, nameof(AssociatedObject.SvgChanged)).Subscribe(SvgChanged),

      Disposable.Create(() => ClearValues()),

      //其他一次性用品
      };
      }

      public override void OnDeattaching()
      {
      // 取消订阅并释放资源。
      _serialDisposable.Disposable = Disposable.Empty;
      }

      * RefCountDisposable – 这个名字总结了它?

    1. MICHAELS9876@GMAIL.COM

      很棒的信息 Moaid,谢谢!

      所以我没有包括 Rx 的原因是它提供了很好的模式来避免内存泄漏模式,如果你正在使用 Rx。
      如果您使用的是常规事件,那么 Rx 并没有真正提供替代方案。好吧,它提供了更好的 Dispose 模式,但您可以轻松地从 Dispose 方法取消订阅常规事件。

      无论如何,我会睡一会儿,也许还会添加 Rx 部分。

    1. MICHAELS9876@GMAIL.COM

      我实际上考虑过包括他们。我喜欢 Rx,但我不知道它们为避免内存泄漏提供了什么优势。你对此有什么见解吗?

  1. 感谢您开始总结。作为一名游戏编程老师,我多年来一直在宣扬这一点。C# 之所以被认为是一门优秀的初学者语言,是因为它隐藏了许多复杂的概念,但正是这样的情况表明,它并没有隐藏和解决它们,而只是隐藏了您最终需要理解的信息。

    令我惊讶的是,我们程序员是多么依赖良好的命名,每天都必须想出好的命名,但往往很糟糕。诚然,这并不是一个糟糕的情况,因为著名的“记忆”或“trie”命名失败,但是为什么许多程序员在它明显不同时将其称为内存泄漏?在您的示例中,任何时候都没有内存泄漏,您只是丢失了一个引用,该引用允许您有选择地取消订阅多播事件的一个回调,但是由于完全清除事件会释放内存恕我直言,这不是内存泄漏。
    这就是为什么我更喜欢引用泄漏这个术语,因为丢失的是订阅者的引用,但事件内部的引用仍然存在,所以内存保持活动是有原因的。

    特别是如果你打算写一篇包含非托管代码的更大的文章,我会认真考虑区分这两者,否则你会导致你在摘要中抱怨的欺骗性的过度简化。

    1. 迈克尔·施皮尔特

      嗨,拉斯,

      太棒了,你教Unity吗?

      好点子!我同意您的看法,托管语言“引用泄漏”和经典的“C++”内存泄漏之间存在区别,在这种情况下您分配内存,然后指向该内存的指针消失了,并且无法释放它。

      我猜这是定义问题。我可以声称术语“内存泄漏”可以包括“引用泄漏”和经典内存泄漏,这是上下文问题。因此,在托管环境中,“内存泄漏”将意味着这两件事。

      这有时令人困惑和不准确,但它似乎是当今大多数开发人员使用的定义。

      在我更大的文章中,我确实在介绍中解决了这种区别,我希望它对读者来说足够清楚。

  2. 迈克尔,

    在您的 IDisposable 实现示例中,您不能只创建一个调用类自己的 Dispose 方法的终结器,还是在您的示例中暗示了这一点?这不能确保始终调用 Dispose 吗?你有什么时候不会被调用的例子吗?

    这是我的典型模式(基于 MS 推荐的 IDisposable 模式),所以如果它有任何问题,我非常想知道。

    谢谢,
    蒂姆

    1. 嗨,蒂姆,
      您说得对,标准的 Dispose 模式就是这样做的。对于本文中介绍的 * 托管内存泄漏,它不太相关。如果对象仍然被事件引用,则永远不会调用终结器。

      当您的对象创建 *native 资源(可能使用 pInvoke)并需要处理它们时,处理模式很有用。我将在我即将发布的帖子中讨论它?

    2. 嗨蒂姆,

      除了迈克尔的回答 - 当 GC 收集具有终结器的对象时,它不会立即释放它的资源。相反,它会安排它在 F-Reachable-Queue 上发布。这会产生一些后果:
      1. 对象(及其所有子对象——参考树)在当前一代中存活并被提升到下一代。
      2. 在终结过程中引用托管对象通常是不安全的,因为无法保证释放资源的顺序,并且您可能会取消引用已经被 GC 处理的对象(实际上不再在内存中可用)。

      第 2 代的收集量比第 1 代少约 10 倍,而第 1 代的收集量也比第 0 代少约 10 倍
      。此外,CLR(每个进程)分配一个专用线程来迭代 F-Reachable-Queue。如果在终结器的代码过程中抛出异常(例如取消引用已释放的托管对象),则线程将出错并且不会调用其他终结器(最终会导致崩溃)。

  3. 您是否注意到第 5 点的检测方法不适用于 .Net Core (2.1.502)?准确地说,调试器不输入终结器代码——永远:-)。看起来终结器只在发布模式下执行,而不是调试模式。尽管如此,这是一篇很棒的帖子,我从中学到了很多东西。谢谢!

  4. 格雷格·阿祖曼尼安

    我还发现了一个没有调用垃圾收集对象的终结器的情况。当类是 UserControl 时。StackOverflow 上对此进行了讨论:https ://stackoverflow.com/questions/17331817/gc-not-finalizing-usercontrol 基本思想是 Dispose 模式的标准实现(如 UserControl 类)包括 GC.SuppressFinalizer() 调用(以防止释放资源 2x)。这可以通过覆盖 Dispose() 来解决,并在调用基本方法后,添加对 GC.ReRegisterForFinalize(this) 的调用。


本文出自勇哥的网站《少有人走的路》wwww.skcircle.com,转载请注明出处!讨论可扫码加群:

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

会员中心
搜索
«    2024年4月    »
1234567
891011121314
15161718192021
22232425262728
2930
网站分类
标签列表
最新留言
    热门文章 | 热评文章 | 随机文章
文章归档
友情链接
  • 订阅本站的 RSS 2.0 新闻聚合
  • 扫描加本站机器视觉QQ群,验证答案为:halcon勇哥的机器视觉
  • 点击查阅微信群二维码
  • 扫描加勇哥的非标自动化群,验证答案:C#/C++/VB勇哥的非标自动化群
  • 扫描加站长微信:站长微信:abc496103864
  • 扫描加站长QQ:
  • 扫描赞赏本站:
  • 留言板:

Powered By Z-BlogPHP 1.7.2

Copyright Your skcircle.com Rights Reserved.

鄂ICP备18008319号


站长QQ:496103864 微信:abc496103864