清理事件处理程序引用的最佳实践是什么?

37

我经常会写出这样的代码:

        if (Session != null)
        {
            Session.KillAllProcesses();
            Session.AllUnitsReady -= Session_AllUnitsReady;
            Session.AllUnitsResultsPublished -= Session_AllUnitsResultsPublished;
            Session.UnitFailed -= Session_UnitFailed;
            Session.SomeUnitsFailed -= Session_SomeUnitsFailed;
            Session.UnitCheckedIn -= Session_UnitCheckedIn;
            UnattachListeners();
        }

这样做的目的是清理我们在目标(Session)上注册的所有事件订阅,以便Session可以被GC释放。我与一位同事讨论了实现IDisposable接口的类,他认为这些类应该执行这样的清理:

    /// <summary>
    /// Disposes the object
    /// </summary>
    public void Dispose()
    {
        SubmitRequested = null; //frees all references to the SubmitRequested Event
    }
有偏好一个方法胜过另一个的原因吗?有没有更好的解决方案?(除了在每个地方都使用弱引用事件)我真正想看到的是类似于安全调用模式来触发事件:即安全和可重复。每次附加到事件时我都能记住要做某些事情,以确保容易清理。
6个回答

57

错误的说法是从Session事件中注销处理程序会以某种方式允许Session对象被GC收集。下面是一张图解释了事件的引用链。

--------------      ------------      ----------------
|            |      |          |      |              |
|Event Source|  ==> | Delegate |  ==> | Event Target |
|            |      |          |      |              |
--------------      ------------      ----------------

在您的情况下,事件源是一个Session对象。但我没有看到您提到哪个类声明了处理程序,因此我们还不知道事件目标是谁。让我们考虑两种可能性。事件目标可以是表示源的相同Session对象,也可以是完全不同的类。在任何情况下,在正常情况下,只要没有其他引用,即使其事件的处理程序保持注册状态,Session也将被收集。这是因为委托不包含对事件源的引用。它只包含对事件目标的引用。

考虑以下代码。

public static void Main()
{
  var test1 = new Source();
  test1.Event += (sender, args) => { Console.WriteLine("Hello World"); };
  test1 = null;
  GC.Collect();
  GC.WaitForPendingFinalizers();

  var test2 = new Source();
  test2.Event += test2.Handler;
  test2 = null;
  GC.Collect();
  GC.WaitForPendingFinalizers();
}

public class Source()
{
  public event EventHandler Event;

  ~Source() { Console.WriteLine("disposed"); }

  public void Handler(object sender, EventArgs args) { }
}
你会看到"disposed"被打印了两次到控制台,这证实了两个实例都没有取消注册事件就被收集了。 test2 所引用的对象被收集的原因是它在引用图中是一个孤立的实体(一旦 test2 被设置为 null),尽管它通过事件有一个对自身的引用。
现在,当你想要事件目标的生命周期短于事件源时,情况变得棘手。在这种情况下,你必须取消注册事件。考虑下面的代码,它演示了这个问题。
public static void Main()
{
  var parent = new Parent();
  parent.CreateChild();
  parent.DestroyChild();
  GC.Collect();
  GC.WaitForPendingFinalizers();
}

public class Child
{
  public Child(Parent parent)
  {
    parent.Event += this.Handler;
  }

  private void Handler(object sender, EventArgs args) { }

  ~Child() { Console.WriteLine("disposed"); }
}

public class Parent
{
  public event EventHandler Event;

  private Child m_Child;

  public void CreateChild()
  {
    m_Child = new Child(this);
  }

  public void DestroyChild()
  {
    m_Child = null;
  }
}
你会发现"disposed"从未打印到控制台,这说明可能存在内存泄漏问题。这是一个特别难解决的问题。在Child中实现IDisposable并不能解决该问题,因为无法保证调用者会正确调用Dispose答案: 如果你的事件源实现了IDisposable,那么你并没有获得任何新的东西。因为如果事件源不再有根,那么事件目标也不再有根。
如果你的事件目标实现了IDisposable,那么它可以从事件源中清除自己,但是无法保证Dispose会被调用。
我并不是说从Dispose中注销事件是错误的。我的观点是你真正需要检查你的类层次结构的定义,并考虑如何尽可能避免内存泄漏问题(如果存在)。

所以基本上,即使您有一堆层次结构中的对象,所有这些对象都相互引用和事件,只要整个块处于真空状态,没有外部代码引用它,它就可以进行垃圾回收? - Kyle Baran
2
@KyleBaran:没错,重要的是没有其他东西引用那个块中的任何内容。如果在你的块中有一个对象的引用,并且该对象将手指伸向你的块中的其他对象,那么一切都不确定了。 - Brian Gideon

6
实现IDisposable有两个优点,胜过手动释放资源的方法:
  1. 它是标准和编译器会特别处理。这意味着每个看到实现IDisposable的人都明白它的作用。
  2. .NET C#和VB通过使用语句提供了特殊的结构来处理IDisposable。
然而,我怀疑在您的情况下是否有用。为了安全地释放对象,它需要在try/catch中的finally块中进行释放。在您所描述的情况下,可能需要Session负责此事,或者在删除该对象时调用Session的代码(即在其范围的末尾:在finally块中)。如果是这样的话,Session也必须实现IDisposable,这遵循通用概念。在IDisposable.Dispose方法内部,它会遍历所有可处置的成员并将其处置。
编辑
您最新的评论使我重新考虑我的答案并尝试连接一些点。您想确保Session被GC释放。如果委托的引用来自同一个类的内部,则根本不需要取消订阅它们。如果它们来自另一个类,则需要取消订阅它们。查看以上的代码块,似乎您会在使用Session的任何类中编写该代码块,并在过程的某个点上对其进行清理。
如果需要释放Session,有一种更直接的方法可以实现,在此过程中调用类无需负责正确处理退订过程。只需使用简单反射循环遍历所有事件并将其设置为null(您可以考虑其他方法以达到相同的效果)。
因为您要求“最佳做法”,所以您应该将此方法与IDisposable结合使用,并在IDisposable.Dispose()内部实现该循环。在进入此循环之前,您调用另一个事件:Disposing,听者可以使用它来清理任何自己的内容。在使用IDisposable时,请注意其注意事项,其中这个简要描述的模式是一种常见的解决方案。

dispose 方法是否会清理其所有事件引用,这可以被视为默认行为吗?当你释放一个完全未知的对象时,你是否期望不必释放事件句柄? - Firoso
@firoso:除非你告诉它,否则dispose不会进行任何清理。关于事件,它可能会变得相当复杂。请查看这篇codeproject文章http://www.codeproject.com/KB/dotnet/EventHandling_GarbageColl.aspx和这篇http://www.thanksmister.com/index.php/archive/be-good-clean-up-your-event-listeners/,你可能会发现没有单一的解决方案能够按预期工作。但总的来说:如果有一个对象的引用或被引用,它将不会被GC回收。这使得事件在一般情况下最难以GC,并且最有可能无限期地悬挂。 - Abel
我很清楚在使用.NET中的事件时与GC相关的影响,但由于我没有使用winforms,第二篇文章是无用的,而第一篇文章包含的所有信息我都知道,我真正想要的是如何以安全的方式处理事件处理程序注册清理的最佳实践。 - Firoso
@firoso:感谢您的笔记,我已经根据这个想法更新了答案。希望现在我更好地理解了您的问题。 - Abel

3

我的偏好是使用一次性资源来管理生命周期。Rx包含一些一次性扩展,让你能够做到以下操作:

Disposable.Create(() => {
                this.ViewModel.Selection.CollectionChanged -= SelectionChanged;
            })

如果您将此存储在某种生命周期正确的 GroupDisposable 中,那么您就可以开始了。

如果您没有使用可处理的和作用域管理生命周期,则绝对值得研究,因为它已成为 .net 中非常普遍的模式。


这个答案差不多了,但是缺少一个关键要素,请看我的回答。感谢@DanH的回答,没有你我永远想不出来! - Contango

3
使用vb.net的WithEvents关键字自动生成的事件处理模式是相当不错的。VB代码(大致):
WithEvents myPort As SerialPort
Sub GotData(Sender As Object, e as DataReceivedEventArgs) Handles myPort.DataReceived Sub SawPinChange(Sender As Object, e as PinChangedEventArgs) Handles myPort.PinChanged
将被翻译成等效的:
SerialPort _myPort;
SerialPort myPort
{  get { return _myPort; }
   set {
      if (_myPort != null)
      {
        _myPort.DataReceived -= GotData;
        _myPort.PinChanged -= SawPinChange;
      }
      _myPort = value;
      if (_myPort != null)
      {
        _myPort.DataReceived += GotData;
        _myPort.PinChanged += SawPinChange;
      }
   }
}
这是一个合理的模式,如果您使用此模式,则在Dispose中,您将设置所有具有相关事件的属性为null,这将进而处理取消订阅它们。
如果想要稍微自动化一下处理,以确保处理掉东西,可以将属性更改为以下内容:
Action<myType> myCleanups; // Just once for the whole class
SerialPort _myPort;
static void cancel_myPort(myType x) {x.myPort = null;}
SerialPort myPort
{  get { return _myPort; }
   set {
      if (_myPort != null)
      {
        _myPort.DataReceived -= GotData;
        _myPort.PinChanged -= SawPinChange;
        myCleanups -= cancel_myPort;
      }
      _myPort = value;
      if (_myPort != null)
      {
        myCleanups += cancel_myPort;
        _myPort.DataReceived += GotData;
        _myPort.PinChanged += SawPinChange;
      }
   }
}
// Later on, in Dispose...
  myCleanups(this);  // Perform enqueued cleanups
请注意,将静态委托钩入myCleanups意味着即使有许多myClass的实例,每个委托系统范围内只需要有一个副本。对于实例较少的类可能不是什么大问题,但对于将被实例化成数千次的类来说,这可能具有潜在的重要性。

1
我发现将常用的全球化事件分离到自己的类中并继承它们的接口可以帮助开发人员使用诸如事件属性以添加和删除事件等方法。无论是否发生封装,都可以通过类内部来清理,可以使用类似下面示例的东西开始清理。
例如:
#region Control Event Clean up
private event NotifyCollectionChangedEventHandler CollectionChangedFiles
{
    add { FC.CollectionChanged += value; }
    remove { FC.CollectionChanged -= value; }
}
#endregion Control Event Clean up

这是一篇关于 Property ADD REMOVE 的其他用途的补充反馈文章: http://msdn.microsoft.com/en-us/library/8843a9ch.aspx

0

DanH的答案几乎正确,但缺少一个至关重要的元素。

为了始终正常工作,必须首先取变量的本地副本,以防它发生变化。本质上,我们必须强制执行一个隐式捕获的闭包。

List<IDisposable> eventsToDispose = new List<IDisposable>();

var handlerCopy = this.ViewModel.Selection;
eventsToDispose.Add(Disposable.Create(() => 
{
    handlerCopy.CollectionChanged -= SelectionChanged;
}));

稍后,我们可以使用以下方式处理所有事件:

foreach(var d in eventsToDispose)
{ 
    d.Dispose();
}

如果我们想让它变得更短:

eventsToDispose.ForEach(o => o.Dispose());

如果我们想要让它更短,我们可以用CompositeDisposable替换IList,因为在幕后它们是完全相同的。
然后我们可以使用以下代码来处理所有事件:
eventsToDispose.Dispose();

@DanH - 非常感谢你,没有你我永远不会想到这个! - Contango

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