找回密码
 立即注册

QQ登录

只需一步,快速开始

graper

高级会员

45

主题

63

帖子

1348

积分

高级会员

积分
1348

活字格认证

graper
高级会员   /  发表于:2009-12-15 15:39  /   查看:6796  /  回复:0
Post by "winking",  10-26-2007, 14:16
-----------------------------------------------------


问:我有许多自定义类型,由于某种原因,它们需要实现 IDisposable。我想确保团队中使用该类的其他开发人员都能正确释放我的类型。如果团队成员之一忘记调用 Dispose,我该如何警告他?

答:首先,如果要对代码执行静态分析,那么 Visual Studio® 2005 和 FxCop 可以帮助解决这一问题。在 msdn2.microsoft.com/ms182289 中描述的 CA2000 规则是“超出范围前释放对象”,它将进行检查,以了解是否已创建任何本地 IDisposable 对象,以及在对象的所有引用超出范围前是否未释放对象。尽管有帮助,但这并不是完美的解决方案,因为通过静态分析只可以检测到这么多问题。您真正需要的是当类型已经过垃圾收集处理但未被释放时,警告使用这些类型的开发人员的一种方法,为此,您可以利用终结器。
一般来说,应避免对类型实现终结器,除非万不得已。而且有了 Microsoft® .NET Framework 2.0 中的 SafeHandle 后,没有什么理由需要您必须如此。然而,在许多情况下,您必须实现 IDisposable,这些情况包括类型拥有其自身可实现 IDisposable 的托管资源;在这种情况下,该类型应提供一个 Dispose 方法,该方法随之会对所含资源调用 Dispose,如图 1 所示。(要想深入了解如何实现 IDisposable,请参阅《MSDN® 杂志》2007 年 7 月刊中 Shawn Farkas 的“CLR 完全介绍”专栏,网址是:msdn.microsoft.com/msdnmag/issues/07/07/CLRInsideOut。)
图 1 中,DisposableClass 不直接使用任何本机资源,因此它不需要提供终结器(当某个派生类型实现终结器时,其 Dispose 方法仍旧调用 GC.SuppressFinalize)。不过,通过添加用于调试的终结器,便可引入一个方法以查明某个类何时未被正确释放,如图 2 所示。只要类型的所有实例都已正确释放,就永远不会调用终结器;但是如果有任何实例未被释放,则终结器线程在发现该实例不再被引用后(这随之会导致调试器中出现一个断言),会调用其 Finalize 方法 (~DisposableClass)。
遗憾的是,尽管使用您的类型的开发人员现在知道自己遗漏了对一个实例的释放,也知道是哪个实例,但他们可能不知道该实例来自哪里。如果您的类型仅在一个位置进行实例化,那就简单了。但是,如果您的类型是在整个代码库中进行实例化的,那么开发人员将无法知道这一特殊实例来自哪里,因此更难以弄清楚此实例是怎样被遗漏掉的。
为了解决这个问题,可以在构造时向您的类中收集更多信息。例如,可以添加以下成员来跟踪堆栈跟踪、线程以及该实例的创建时间:
  1. #if DEBUG
  2.     private StackTrace _stack = new StackTrace(true);
  3.     private int _threadId = Thread.CurrentThread.ManagedThreadId;
  4.     private DateTime _time = DateTime.UtcNow;
  5.     ...
  6. #endif
复制代码
当执行终结器时,还可以修改 Debug.Fail 以包括来自这些成员的信息,从而帮助用户精确查明该实例来自哪里,并跟踪它为何没有被正确释放。注意,以前代码段中的编译指令 (#if/#endif) 是用于确保所有这些代码仅被编译到 DEBUG 版本中,而非 RELEASE 版本。检索堆栈跟踪是开销相对较大的操作,如果性能是要关注的问题的话,应尽可能避免它。
另外还可以注意到,在图 2 中,我用同样的方式对终结器进行补充。这主要是个风格问题。即使终结器被取消,它们也会给系统添加一些开销,所以如果它不是正确功能所必需的,在我们的类中就不需要有终结器。(分配后,可终结对象将被添加到终结列表中。当不能再获得这些实例且 GC 运行时,这些实例将被移动到“FReachable”队列中,该队列由终结器线程进行处理。使用 GC.SuppressFinalize 取消终结将在对象的标头设置“不要运行我的终结器”标记,这样对象就不会被 GC 移到 FReachable 队列中。因此,给予对象终结器仍属系统开销,尽管极小,即使终结器什么也不做或被取消也是如此)。
这非常适用于单个类型,但是如果要创建许多需要类似行为的类型,大量编码实践的经验要求您将该代码分解到一个可重用的类。这实际上是一个极具迷惑性、看似棘手的问题。下面我将介绍几种不同的实现并说明各个实现的利弊,作为后半部分回答。您可以根据需要从各种实现中进行挑选。
所有实现的一个共同点是在构造时需要存储各种数量的信息。为此,我选择创建自定义异常类。当构造完成后,这个异常类可以检索与前面提到的数据类似的数据。然后,可以在调用终结器时引发它,或者将其 ToString 方法用作调用 Debug.Fail 或类似对象的参数。(请注意,对于 .NET Framework 2.0 及更高版本,在默认情况下从终结器线程引发异常会毁坏应用程序。这与以前版本不同,在以前版本中,从终结器线程引发的异常会被运行时隐含消化掉,而且无任何提示。我已选择不引发异常,不过如果您认为不释放对象是足以终止进程的严重错误,那就可以随时取消对相关行的注释。)我的 InvokedFinalizerException 如图 3 所示。
准备就绪后,第一个方法就是将所有跟踪代码移到一个单独的可构造类 FinalizationDebugger 中,如图 4 所示。该类的实例将存储为可释放类的成员,并在构造您的类时进行构造。当释放您的类时,这个单独的 FinalizationDebugger 类也被释放。如果您的类未被释放,当它不再被引用时,FinalizationDebugger 最终将被收集,并且其终结器将提供所有相关信息,我们需要这些信息以查明哪些实例未被正确释放,它来自哪里等问题。注意,虽然 FinalizationDebugger 保留对您对象的引用,但是由于该对象仅包含对此 FinalizationDebugger 实例的引用,因此它对该象的引用不能防止该对象被收集(如果 .NET Framework 垃圾收集器使用的是引用计数方案而不是实际上使用的标记和清除方案,这个循环可能会有问题)。当然,正如您在图 4 中看到的那样,在可释放类中使用它仍需要添加一些东西。实际上,这并不比我们最初的方法强多少,当时我们也是试图重构一个更干净利落的解决方案。
下一个方法将此步骤向前推进了一步,即将所有这些代码移到可以派生可释放类的基类中。该方法如图 5 所示。注意,这将使可释放类变得非常干净,因为需要的唯一更改是为类型添加 FinalizationDebuggerBase 作为基类,删除无参数 Dispose 方法(因为它已经由基类实现),并更改 Dispose(bool) 方法以覆盖基实现。
遗憾的是,该方法存在一些问题。首先,当编译 FinalizationDebuggerBase 时,#if/#endif 编译指令将在编译时进行评估。如果 FinalizationDebuggerBase 与 DisposableClass 属于相同的项目,它将始终选取同一个 DEBUG/RELEASE 编译标记作为您的类,并保持同步,这当然是您希望发生的。但如果 FinalizationDebuggerBase 位于另一个程序集中(例如,如果它被编译到贵公司的许多项目共享的程序集中),那么您引用的程序集已编译,这意味着它将不考虑您为项目所做的 DEBUG/RELEASE 选择。这样即可在 RELEASE 模式下编译项目,并期望不包括此终结代码的任何一部分。但如果 FinalizationDebuggerBase 类在 DEBUG 模式下编译,这种期望将无法实现。第二个问题应该是个更明显的问题,即 .NET 不支持多类继承,这表示一个类只能拥有一个基类(尽管它可以实现任何数量的接口)。也就是说,如果您的类已经拥有一个基类,便无法使用此方法(除非可以设法将此基类置于层次结构中)。
第三个方法就是将一切构建成一个您的实例可以调用的静态类。关于此方法的实现如图 6 所示。在此方法中,FinalizationDebugger 是静态类,它公开三个静态方法:Constructor、Dispose 和 Finalizer。基本思路是:从类中的适当位置调用这些方法,该适当位置应能从这些方法的名称中一目了然(参见图 6 的示例)。这将最低限度地影响代码,因为通常只需添加三行代码(即使类型目前还没有终结器,仍需要添加这几行代码)。所有这些方法都用 ConditionalAttribute 进行标记,这样,在 DEBUG 模式下编译类时,它们只能被该类调用。
此类的实现仍需要稍微解释一下。FinalizationDebugger 包含一个静态 Dictionary,将对象映射到自定义异常类,该异常类包含何时以及如何构造相关对象的所有信息。当对象被构造并调用 Constructor 方法时,会将自身作为一个参数传递,该对象将与此异常的一个实例一起插入到这个表中。当 Dispose 方法被调用时,对象及其相关的异常将从表中删除。当 Finalizer 方法被调用时,将查询表以查看该对象是否存在;如果存在,则检索相关异常,并将其用于断言。(如果需要,它还可以被引发。)当然,该实现有一些复杂。
首先,因为这些是静态方法,可以想像得出,它们可能从多个线程同时被调用,因此我们需要保护对共享数据的访问。为此,我使用一个监视器来防护对基本 Dictionary 的所有访问。
第二个问题更加有趣。Dictionary 是 FinalizationDebugger 类的一个静态成员,这使得 Dictionary 成为一个 GC 根。这样,存储在此 Dictionary 中的任何对象将永远不能被垃圾收集器收集,因为它们将始终处于可访问状态。如果我只是要将您的可释放类实例存储到此 Dictionary 中,它们将永远不能用于垃圾收集,因此也就永远不能被终结,这将破坏整个系统(更不用说引起大量内存泄漏了)。为了解决这个问题,我们需要弱引用。
System.WeakReference 类利用垃圾收集器提供的功能和 System.GCHandle。在为对象实例化 WeakReference 时,WeakReference 将在内部为该对象分配一个弱 GCHandle(GCHandleType.Weak 或 GCHandleType.WeakTrackResurrection)。它只是存储此 GCHandle,而不是存储一个对您对象的引用。弱 GCHandle 用于跟踪对象,但仍允许收集该对象(当对象被收集时,GCHandle 的内容将清零)。因此,WeakReference 允许您访问基本对象,但它不保留对对象的强引用,这样,当垃圾收集器开始运行,且对象可以通过其他方法被收集时,它将被收集,而且 WeakReference 在未来尝试访问该对象时都将返回空值。这样,WeakReferences 就解决了我们的静态 Dictionary 问题。通过存储 WeakReferences 而不是实际对象,对象将仍处于可收集状态。
不过,这会导致另一个问题。Dictionary 使用对象的哈希代码及其 Equals 方法来确定对象存储在 Dictionary 中的什么位置,以及对象是否已经存在于 Dictionary 中(用于查询等)。但是,WeakReference 引用类型并不会覆盖 GetHashCode 或 Equals;它的相等语义默认为与 System.Object 的相同,因此要检查两个被比较的 WeakReferences 实例是否完全相同。结果,以下代码将输出“False”两次:
  1. object a = new object();
  2. Dictionary<object, bool> dict = new Dictionary<object, bool>();
  3. dict.Add(new WeakReference(a), true);
  4. Console.WriteLine(dict.ContainsKey(a));
  5. Console.WriteLine(dict.ContainsKey(new WeakReference(a)));
复制代码
为了解决这一问题,可以尝试两种解决方案。第一个是修改 FinalizationDebugger.Constructor 方法,以返回创建的 WeakReference。然后,DisposableClass 就可以占用此 WeakReference,并在未来调用 FinalizationDebugger.Dispose 和 FinalizationDebugger.Finalizer 时提供它(不是本身):
  1. private WeakReference _weakRef;
  2. ...
  3. _weakRef = FinalizationDebugger.Constructor(this);
  4. ...
  5. FinalizationDebugger.Finalizer(_weakRef);
复制代码
不过,这也会带来另一个问题。由于未定义对象的终结顺序,因此在另一个对象的终结器中引用一个可终结实例(它可能已经被终结)并不是个好主意。WeakReference 本身可以实现一个终结器,这样,以前的代码段导致尝试使用一个 WeakReference,该 WeakReference 可能已通过可释放类型的终结器终结。
第二个解决方案是我选择要实现的方案,在 WeakReference 上实现 GetHashCode 和 Equals,它从根本上解决了该问题。为此,我创建了图 7 所示的类 ObjectEqualityWeakReference,它覆盖了 WeakReference 的 Equals 和 GetHashCode 方法,以提供我需要的语义。在构造 ObjectEqualityWeakReference 后,它会将所提供对象的哈希代码缓存到一个成员变量中,从被覆盖的 GetHashCode 返回的就是该缓存值;这样,哈希代码将基于基本对象。即使对象被收集,ObjectEqualityWeakReference 仍将继续返回同一哈希代码值。对象的哈希代码应永远不变,Dictionary 使用哈希代码作为在其表中查找对象的第一步;如果添加对象后对象的哈希代码发生了变化,Dictionary 很可能找不到它。
ObjectEqualityWeakReference 也会覆盖 Equals 方法。在将 ObjectEqualityWeakReference 的一个实例与引用同一基本对象的另一个 WeakReference 比较时,或者直接将其与另一个对象比较时,我希望 Equals 能够奏效。为此,Equals 首先会查看被比较的对象是否为另一个 WeakReference。如果是,Equals 将返回 WeakReference 实例是否相同,或者基本对象是否相同。如果被比较的对象不是 WeakReference,Equals 将返回它是否与基本对象匹配。(希望支持添加 ObjectEqualityWeakReference 实例,而同时被基本对象类型查询,这是 Dictionary 的 TKey 类型参数被绑定到 Object 而不是 ObjectEqualityWeakReference 的原因。)
关于 ObjectEqualityWeakReference 还有重要的一点需要注意,那就是它的构造函数:
  1. public ObjectEqualityWeakReference(object obj) : base(obj, true)
复制代码
此构造函数委托给接受两个参数(而非一个)的 WeakReference 的构造函数。第二个参数是布尔参数,它指示 WeakReference 何时停止跟踪对象。如果值为 false,到终结时才会停止跟踪对象;如果值为 true,到终结后才会停止跟踪对象。我们需要对象在调用 FinalizationDebugger.Finalize 的过程中继续存在,因此将此值设为 true。注意,此布尔参数只指示创建什么类型的 GCHandle。如果值为 false,将创建一个 GCHandle.Weak,如果值为 true,则将创建一个 GCHandle.WeakTrackResurrection。这种命名来自于复活的概念,即对象可以在终结时得到保存。如果对象的终结器创建了针对对象的新的根引用(例如,通过将对象存储到某处的静态字段中),则对象现在将再次可以被访问,因此也就不会被收集。为了能使用 WeakReference 来实现这一点,在终结阶段结束前 GCHandle 不能允许收集对象,因此,将上文介绍的行为命名为“WeakTrackResurrection”。
这样就形成了一个完整的静态类解决方案,它非常有效。不过,如同其他方法一样,它也有缺点。举个例子,由于需要被保护的静态 Dictionary 的原因,此方法需要锁,这在要同时构造和释放很多对象时,就会引起争用。可以采用几个方法在某种程度上缓解这个问题。首先,将在锁定区域执行的工作保持在最少程度 — 我只将 Dictionary.Add 调用封装到 Constructor 中,将 Dictionary.Remove 调用封装到 Dispose 中,将 Dictionary.TryGetValue 和 Dictionary.Remove 调用封装到 Finalizer 中。其次,根据被跟踪对象的类型,我将 FinalizationDebugger 设为一个泛型类。这实际上是根据您使用的类型(单独的锁和词典)创建一个全新的 FinalizationDebugger 类型,这样,只有基于相同类型的那些调用才会引起争用。
该方法的另一个缺点是,锁定终结器并不是要做的最重要的事情。如果由于某种原因,终结器在一定的时间内无法获得锁,则 CLR 可能就会中止终结器,不过这在当前的代码情况下几乎不可能。
我认为此方法的最大缺点是,它要求可释放类实现一个终结器,以便它能调用 FinalizationDebugger.Finalizer 方法。如果 Finalizer 方法一直未被调用,则永远不会进行检查,以确定对象是否没有被正确释放。如果您的类已经实现一个终结器,就没什么大不了的;否则,您就需要为此添加一个终结器。
所有这些方法都有其他变体,而且我确信还有其他我没有考虑到的方法。如果您有一个新方法,认为它能解决这些方法对应的各种问题,我愿洗耳恭听。同时,这里的任何一种方法都能帮助您让使用可释放类型的开发人员跟踪未正确释放您的对象的情况。

0 个回复

您需要登录后才可以回帖 登录 | 立即注册
返回顶部