65.9K
CodeProject 正在变化。 阅读更多。
Home

IDisposable:你的母亲从未告诉过你的资源释放

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (155投票s)

2008年9月21日

BSD

37分钟阅读

viewsIcon

532235

通过可释放设计原则(Disposable Design Principle)克服 IDisposable 接口的一个难题。

引言 - 推荐阅读

本文中许多关于 .NET 内部机制的信息都源自 Wintellect 的 Jeffrey Richter 编写的《CLR via C#》(第二版),由 Microsoft Press 出版。如果你还没有这本书,去买一本吧。现在就去。说真的,它对任何 C# 程序员来说都是必不可少的资源。

枯燥的部分 - IDisposable 的误区

本文分为两部分:第一部分评估了 IDisposable 固有的许多问题,第二部分则提出了一些编写 IDisposable 代码的“最佳实践”。

确定性资源释放的必要性

在二十多年的编程生涯中,我偶尔发现有必要为某些任务设计自己的小型编程语言。这些语言从命令式脚本语言到用于树的专用正则表达式不等。在语言设计中需要做出许多选择:一些简单的规则永远不应被打破,还有许多通用的指导方针。其中一条指导方针是:

永远不要设计一门包含异常但又没有确定性资源释放的语言。

猜猜看 .NET 运行时(以及由此衍生的每一种 .NET 语言)没有遵循哪条指导方针?本文将探讨这一选择带来的一些后果,然后提出一些最佳实践来尝试处理这种情况。

这条指导方针的原因在于,确定性资源释放对于编写代码可维护的可用程序是必需的。确定性资源释放提供了一个确切的时间点,程序员可以知道资源已被释放。编写可靠软件有两种方法:传统方法是尽快释放资源,而现代方法则让资源在不确定的时间释放。现代方法的优点是程序员不必显式地释放资源。缺点是编写可靠软件变得更加困难;分配逻辑中增加了一整套难以测试的错误条件。不幸的是,.NET 运行时的设计采用了现代方法。

.NET 运行时对不确定性资源释放的支持是通过 Finalize 方法实现的,该方法对运行时具有特殊意义。微软也认识到确定性资源释放的需求,并添加了 IDisposable 接口(以及我们稍后将讨论的其他辅助类)。然而,运行时将 IDisposable 视为一个普通接口,没有任何特殊含义;这种将其降级为二等公民的做法导致了一些困难。

在 C# 中,可以通过使用 tryfinally,或者不那么简洁的等价物 using,来实现一种“简陋的确定性释放”。微软内部曾就应不应该使用引用计数有过很多争论,我个人认为他们做出了错误的决定。结果是,确定性资源释放需要使用要么是笨拙的 finally/using,要么是容易出错的直接调用 IDisposable.Dispose。对于习惯了例如 C++ 的 shared_ptr<T> 的可靠软件程序员来说,这两种方式都不是特别有吸引力。

IDisposable 解决方案

IDisposable 是微软为实现确定性资源释放提供的解决方案。它旨在用于以下场景:

  • 任何拥有托管(IDisposable)资源的类型。请注意,包含类型必须拥有这些资源,而不仅仅是引用它们。这里的一个困难是,并不明显哪些类实现了 IDisposable,这要求程序员不断查阅每个类的文档。FxCop 在这种情况下能提供帮助,如果程序员忘记了,它会标记代码。
  • 任何拥有非托管资源的类型。
  • 任何同时拥有托管和非托管资源的类型。
  • 任何派生自 IDisposable 类的类型。我不建议从拥有非托管资源的类型派生;通常更清晰的面向对象设计是将这类类型作为字段而不是基类。

IDisposable 确实提供了确定性释放;然而,它也带来了一系列问题。

IDisposable 的困难 - 可用性

在 C# 中,正确使用 IDisposable 对象是件麻烦事。任何在局部使用 IDisposable 对象的人都必须知道要将其包装在 using 结构中。让这件事变得尴尬的是,C# 不允许 using 包装非 IDisposable 的对象。因此,对于在确定性程序中使用的每个对象,程序员必须通过不断查阅文档或将所有东西都包装在 using 中然后移除那些导致编译错误的对象,来确定是否需要 using

C++ 在这方面稍好一些。它们支持引用类型的栈语义,其逻辑等同于仅在必要时插入 using。如果 C# 允许 using 包装非 IDisposable 对象,将会受益匪浅。

这个关于 IDisposable 的问题是一个最终用户的问题:可以通过代码分析工具和编码规范来缓解,尽管没有完美的解决方案。雪上加霜的是,如果资源在不确定的时间被释放(即程序员在需要时忘记了 using),那么代码在测试期间可能运行良好,而在实际使用中却莫名其妙地失败。

依赖 IDisposable 而非引用计数的引用也带来了所有权问题。当 C++ 的引用计数 shared_ptr<T> 的最后一个引用超出作用域时,它的资源会立即被释放。相比之下,基于 IDisposable 的对象将负担放在了最终用户身上,要求他们明确定义哪段代码“拥有”该对象,并因此负责释放其资源。有时,所有权是显而易见的:当一个对象被另一个对象拥有时,容器对象也实现 IDisposable,并由其所有者释放。在另一种非常常见的情况下,对象的生命周期可以由其在程序某处的代码作用域定义,最终用户使用 using 结构来定义“拥有”该对象的代码块。然而,还有一些其他情况,对象的生命周期可能由不同的代码路径共享,这些情况对最终用户来说更难正确编码(而引用计数的引用会为这个问题提供一个简单的解决方案)。

IDisposable 的困难 - 向后兼容性

向接口或类中添加或移除 IDisposable 是一个破坏性变更。正确的设计意味着客户端代码将主要使用接口,因此在这种情况下,IDisposable 可以被添加到 internal 类中,绕过接口。然而,这仍然可能对旧的客户端代码造成问题。

微软自己也遇到了这个问题。IEnumerator 并不派生自 IDisposable;然而,IEnumerator<T> 却派生了。现在,当旧的客户端代码期望 IEnumerable 集合,却被给予 IEnumerable<T> 集合时,它们的枚举器就没有被正确地释放。

这是世界末日吗?可能不是,但这确实引发了一些关于 IDisposable 的二等公民地位如何影响设计选择的问题。

IDisposable 的困难 - 设计

IDisposable 在代码设计领域引起的最大缺点是:设计的每个接口都必须预测其派生类型是否需要 IDisposable。原话是:“在一个通常有派生类型持有资源的基类上实现 Dispose 设计模式,即使基类本身不持有资源”(来自 实现 Finalize 和 Dispose 以清理非托管资源)。从设计的角度来看,接口需要预测该接口的实现是否需要 IDisposable

如果一个接口没有继承自 IDisposable,但它的一个实现需要它(例如,一个来自第三方供应商的实现),那么最终用户的代码会遇到“切片”问题。最终用户的代码没有意识到它正在使用的类需要被释放。最终用户的代码可以通过显式测试其使用的对象是否实现了 IDisposable 接口来应对这个问题;然而,这将把笨拙的 using 变成针对每个抽象局部对象都使用一个真正丑陋的 finally 结构。在我看来,把这种负担放在最终用户身上是不可接受的(至少在没有语言支持的情况下)。

这个“切片”问题是微软在更新 IEnumerator 时遇到的问题的泛化;他们认为枚举器通常需要释放资源,所以他们向 IEnumerator<T> 添加了 IDisposable。然而,当最终用户代码使用旧的 IEnumerator 接口时,向派生类型添加 IDisposable 会导致切片问题。

对于某些接口,它们的派生类型是否需要 IDisposable 是相当明显的。但对于其他接口,情况就远没有那么清晰了,然而在接口发布时必须做出决定。在一般情况下,这是一个真正的问题。

简而言之,IDisposable 阻碍了可重用软件的设计。造成这一切困难的根本问题是违反了面向对象设计的核心原则之一:保持接口与实现的分离。接口实现内部所分配和释放的资源类型应该是一个实现细节。然而,当微软决定只将 IDisposable 作为二等公民来支持时,他们就决定将资源释放视为接口的一部分,而不是实现细节。他们错了,这些困难就是违反分离原则的结果。

有一个相当不吸引人的解决方案:让每个接口和每个类都继承自 IDisposable。由于 IDisposable 可以实现为空操作(noop),这实际上只意味着派生接口或类可能有一个实现、派生类或未来版本会释放资源。我个人还没有足够的勇气将此作为设计指南——但也许以后会。

关于 IDisposable 设计的最后一个困难是它们如何与集合交互。由于 IDisposable 是一个接口,要么集合在“拥有”其项目时必须表现不同,要么最终用户必须在必要时记得显式调用 IDisposable.Dispose。如果将这个责任放在集合类上,那就意味着需要一套新的“拥有”其项目的集合类;而复制一个类层次结构对任何设计师来说都是一个危险信号,表明有问题存在。如果 .NET 支持引用计数的引用(即 IDisposable 作为一等公民),那么这一切都不会成为问题。

IDisposable 的困难 - 额外的错误状态

IDisposable 的另一个困难是它可以被显式调用,并且与对象的生命周期没有绑定。特别是,这为每个可释放对象增加了一个新的“已释放”状态。随着这个状态的增加,微软建议每个实现 IDisposable 的类型在每个方法和属性访问器中检查自己是否已被释放,如果是,则抛出异常。呃……这让我想起我曾经的一个同事,他坚持认为我们每次分配内存时都应该运行内存校验和算法,“以防”RAM 即将失效。在我看来,检查已释放状态只是浪费周期,只在调试代码中有用。如果最终用户连最基本的软件契约都不能遵守,他们无论如何也写不出能工作的代码。

我建议支持“未定义行为”,而不是检查已释放状态并抛出异常。访问一个已释放的对象相当于现代编程中访问已释放的内存。

IDisposable 的困难 - 没有保证

由于 IDisposable 只是一个接口,实现 IDisposable 的对象只支持确定性释放;它们不能要求这样做。由于最终用户释放对象被认为是完全可以接受的(我不同意这个惯例),任何 IDisposable 对象都必须支持额外的逻辑来处理不确定性释放以及确定性释放。再次强调,不确定性释放是每个 .NET 对象都必须支持的标准,而确定性释放只是一个不能强制执行的可选附加项。真正强制执行确定性资源释放需要引用计数的引用。

IDisposable 的困难 - 实现的复杂性

微软有一个用于实现 IDisposable代码模式。没有多少程序员完全理解这段代码(例如,为什么需要调用 GC.KeepAlive,以及为什么不需要同步 disposed 字段)。还有几篇其他文章详细描述了如何实现这个代码模式。以下是其有些晦涩的设计背后的原因:

  • IDisposable.Dispose 可能永远不会被调用,所以可释放对象必须包含一个释放资源的终结器(finalizer)。换句话说,确定性释放必须支持不确定性释放。
  • IDisposable.Dispose 可以被多次调用而不会产生不良副作用,所以任何真正的释放代码都需要通过检查它是否已经运行过来保护。
  • 因为终结器以任意顺序在所有不可达对象上运行,所以终结器不能访问托管对象。因此,资源释放方法必须能够处理“正常”释放(从 IDisposable.Dispose 调用时)和“仅非托管”释放(从 Object.Finalize 调用时)。
  • 由于终结器在单独的线程上运行,有可能在 IDisposable.Dispose 返回之前终结器被调用。审慎使用 GC.KeepAliveGC.SuppressFinalize 可以防止竞争条件。

此外,以下事实常常被忽略:

  • 如果构造函数抛出异常,终结器会被调用(这是另一个我不同意的惯例);因此,释放代码必须能优雅地处理部分构造的对象。
  • 在派生自 CriticalFinalizerObject 的类型上实现 IDisposable 很棘手,因为 void Dispose(bool disposing)virtual 的,但它必须在一个约束执行区域(Constrained Execution Region)内运行。这可能需要显式调用 RuntimeHelpers.PrepareMethod

推荐的 IDisposable 代码模式的命名约定充其量是令人困惑的。对象正在实现 IDisposable 接口,并需要一个布尔字段 disposed。到目前为止还不错,但是接着:作为代码模式的一部分,IDisposable.Dispose 的实现调用了重载的 Dispose,带有一个布尔参数 disposing,该参数指示可以进行哪种类型的释放(而不是是否正在进行释放)。即使对于资深的 C# 程序员,如果他们不经常回顾这个代码模式,其命名约定也会令人困惑。任何偏离都会被 FxCop 标记为违反此代码模式。

这是 C++ 再次比 C# 略胜一筹的领域。C++ 编译器由于其析构函数语法,完成了大部分实现 IDisposable 的工作。C# 是一种纯粹的 .NET 语言,所以在很多方面,其语法比 C++ 更自然。然而,当涉及到确定性资源释放时,C++ 确实有两个有用的优势:析构函数语法和引用类型的栈语义。

即使对于完美的程序员来说,推荐的 IDisposable 代码模式的复杂性也增加了在库中或由同事编写错误代码的可能性。将关闭逻辑放在 Dispose 方法中是一种自然的冲动。然而,由于我们在终结器中不能接触托管对象,并且由于 IDisposable 只支持确定性释放而不是强制执行它,这通常是一个错误。推荐的 IDisposable 代码模式只能用于释放资源;它不能用于支持通用的关闭逻辑。这个事实太容易被忘记了。

有时,为了简单或出于偶然,IDisposable 就被遗忘了。FxCop 会捕捉到其中一些违规行为(例如常见的对象包含 IDisposable 对象的情况),但它会漏掉其他情况。微软的程序员自己也掉进了这个陷阱:WeakReference 没有实现 IDisposable,而它肯定应该实现。不相信确定性资源释放必要性的程序员可能会完全忽略 IDisposable

IDisposable 的困难 - 无法实现关闭逻辑(托管终结)

在任何实际应用中,关闭逻辑都是一个普遍的需求。在异步编程模型中尤其如此。例如,一个拥有自己子线程的类可能希望通过设置一个 ManualResetEvent 来停止该线程。虽然在直接调用 IDisposable.Dispose 时这样做是完全合理和预期的,但如果从终结器中调用,这将是灾难性的。由于 IDisposable 并不强制确定性释放,对于忘记或忽略调用 IDisposable.Dispose 的最终用户,甚至没有警告;他们的程序只是缓慢地泄漏资源。这就提出了一个问题:终结器代码有任何方法可以访问托管对象吗?

为了更好地理解对终结器的限制,我们必须了解垃圾回收器。[注意:这里给出的垃圾回收和终结的描述是一个简化版本;实际实现要复杂得多。代、复活、弱引用以及其他几个主题都被忽略了。然而,就本文而言,这个逻辑描述是正确且相当完整的。]

.NET 垃圾回收器使用标记/清除算法。具体来说,它执行以下逻辑等价操作:

  1. 暂停所有线程(除了终结器线程)。
  2. 创建一组“根”对象。如果 AppDomain 正在卸载或 CLR 正在关闭,则没有根对象。对于正常的垃圾回收,根对象是:
    • 静态字段。
    • 每个线程的整个调用栈的方法参数和局部变量,除非当前的 CLI 指令已经越过了它们最后一次访问的点(例如,如果一个局部变量只在函数的前半部分使用,那么它在函数的后半部分就有资格被垃圾回收)- 注意,this 指针也包含在此处。
    • 正常和固定的 GCHandle 表条目(这些用于 Interop 代码,因此 GC 不会移除仅由非托管代码引用的对象)。
  3. 递归地将每个根对象标记为“可达”:对于可达对象中的每个引用字段,该字段引用的对象被递归地标记为可达(如果尚未标记)。
  4. 将剩余的、未标记的对象识别为“不可达”。
  5. 递归地将每个带有终结器(且未对其调用 GC.SuppressFinalize)的不可达对象标记为可达,并以一种基本不可预测的顺序将它们放入“终结可达队列”。

与上述垃圾回收并行,终结也在后台持续运行:

  1. 一个终结器线程从终结可达队列中取出一个对象,并执行其终结器 - 注意,多个终结器线程可能在任何给定时间为不同的对象执行终结器。
  2. 然后该对象被忽略;如果它仍然可以从终结可达队列中的另一个对象到达,它将被保留在内存中;否则,它将被视为不可达,并将在下一次垃圾回收扫描时被回收。

终结器不能访问托管对象的原因是,它们不知道其他哪些终结器已经运行。任何有指向其他对象的字段的对象都可以访问这些字段,因为其他对象仍将在内存中,但不能对它们做任何事情,因为它们的终结器可能已经运行(从而释放了它们)。即使调用 Dispose 也是一个错误,因为 Dispose 可能已经在另一个终结器线程的上下文中运行。调用 Dispose 无论如何都是没有意义的,因为那些对象要么可以从活动代码到达,要么已经在终结可达队列中。另请注意,在 AppDomain 卸载或 CLR 关闭的情况下,所有对象都变得有资格被垃圾回收,包括 CLR 运行时支持对象和静态引用字段;在这种情况下,甚至像 EventLog.WriteEntry 这样的静态方法都不能调用。

确实有少数例外情况,终结器可以访问托管对象:

  • 终结可达队列是部分有序的:派生自 CriticalFinalizerObject 的类型的终结器在派生自非 CriticalFinalizerObject 的类型的终结器之后被调用。这意味着,例如,一个有子线程的类可以为其包含的 ManualResetEvent 调用 ManualResetEvent.Set,只要该类不派生自 CriticalFinalizerObject
  • Console 对象和 Thread 对象上的一些方法被给予了特殊考虑。这就解释了为什么示例程序可以在其终结器中创建一个调用 Console.WriteLine 的对象然后退出,但同样的程序使用 EventLog.WriteEntry 却不行。

总的来说,终结器不能访问托管对象。然而,对相当复杂的软件来说,支持关闭逻辑是必要的。Windows.Forms 命名空间通过 Application.Exit 处理这个问题,它会启动一个有序的关闭过程。在设计库组件时,有一种支持关闭逻辑的方法与现有逻辑上相似的 IDisposable 集成是很有帮助的(这避免了定义一个没有内置语言支持的 IShutdownable 接口)。这通常通过在调用 IDisposable.Dispose 时支持有序关闭,而在未调用时支持中止关闭来完成。如果终结器能够尽可能地用于有序关闭,那就更好了。

微软也遇到了这个问题。StreamWriter 类拥有一个 Stream 对象;StreamWriter.Close 会刷新其缓冲区,然后调用 Stream.Close。然而,如果一个 StreamWriter 没有被关闭,它的终结器就无法刷新其缓冲区。微软通过不给 StreamWriter 一个终结器来“解决”这个问题,希望程序员会注意到丢失的数据并推断出他们的错误。这是需要关闭逻辑的一个完美例子。

短暂的间歇 - 我们现在在哪

“但是,当然,这一切都太乱了。强迫开发者担心这类事情,与我们新的托管平台的目标背道而驰。” (Chris Brumme, “生命周期、GC.KeepAlive、句柄回收”, 博客文章 2003-04-19)

<sarcasm strength="mild">啊,是的。.NET 确实让事情变得简单了。怎么说呢,C++ 中的非托管析构函数可比这一切复杂了。</sarcasm> 说真的,如果这篇文章包含像复活和派生自 CriticalFinalizerObject 的终结器的限制这样真正复杂的问题,它可能会长得多。

我想花点时间来赞美一下 .NET 和 C#。虽然我确实不同意微软的几个决定,但总的来说,他们做得非常出色。我非常喜欢任何能将过程式和函数式编程家族更协同地结合在一起的语言,而 C# 在这方面做得非常好。.NET 框架和运行时有一些粗糙的角落,但总的来说,它们比以前的要好,而且它们显然是未来的发展方向。到目前为止,我一直在指出 IDisposable 引起的问题,从现在开始,我将开始着手解决其中几个问题。

事实是,IDisposable 现在已经内置于 .NET 语言中(虽然不是运行时),任何解决方案都需要利用这个接口。可以说,我们被它困住了,所以让我们尽力而为吧。

(希望)不那么枯燥的部分 - IDisposable 的规范化

本文的第一部分讨论了 IDisposable 的困难;这部分将介绍一些编写 IDisposable 代码的“最佳实践”。

解决 IDisposable 的困难 - 通过利用可释放设计原则最小化 IDisposable 的用例

微软推荐的 IDisposable 代码模式之所以复杂,一个原因是因为他们试图涵盖太多的用例。在设计 IDisposable 类时稍加约束,将大有裨益:

  • 对于每个非托管资源,创建一个(可能是 internal 的)专门负责释放它的 IDisposable 类。微软在 BCL 实现中彻底遵循了这一原则。请注意,非托管资源的包装类型被视为托管资源。
  • 永远不要从非托管资源包装类型派生。
  • 创建其他托管的 IDisposable 类型,这些类型要么拥有托管资源,要么派生自拥有托管资源的类型。
  • 在任何情况下,实现 IDisposable 时都不要创建一个需要同时考虑托管和非托管资源的类型。这大大简化了实现,减少了可能的错误。

可释放设计原则(Disposable Design Principle)建立在这些思想之上:

  • 0 级类型直接包装非托管资源。这些类型通常是 sealed 的。
  • 1 级类型是派生自 1 级类型和/或包含 0 级或 1 级类型字段成员的类型。

为了详细阐述这个设计原则,那些包装非托管资源的小型 privateinternal 0 级类应该尽可能地接近原生 API,并且只关心正确地释放资源。所有其他的 API 都应该在一个拥有 0 级字段成员的 1 级类中提供。这将导致两个松散相关的类(或类层次结构):一个只负责包装非托管资源,另一个只需要引用一个托管资源。这将我们对 IDisposable 的用例减少到只有两个:

  1. 0 级类型:只处理非托管资源。
  2. 1 级类型:只处理托管资源(由基类和/或字段定义)。

在 1 级类型上实现 IDisposable 相当简单:只需将 IDisposable.Dispose 实现为调用任何 IDisposable 字段的 Dispose,然后,如果该类型派生自 IDisposable 类型,则调用 base.Dispose。这里不适合通用的关闭逻辑。对于这个简单的实现,请注意以下几点:

  • Dispose 可以安全地被多次调用,因为 IDisposable.Dispose 可以安全地被多次调用,而它所做的就是这些。
  • 1 级类型不应该有终结器;反正它们也做不了什么,因为无法访问托管代码。
  • Dispose 的末尾调用 GC.KeepAlive(this) 是不必要的。即使垃圾回收器有可能在 Dispose 仍在运行时回收此对象,这也不危险,因为所有被释放的资源都是托管的,并且此类型或任何派生类型都没有终结器。
  • 同样,调用 GC.SuppressFinalize(this) 也是不必要的,因为此类型或任何派生类型都没有终结器。

然而,对于第一个用例,正确实现 IDisposable 仍然很困难。由于为非托管资源正确实现 IDisposable 的复杂性,实际上最好是我们完全不实现它。这可以通过勤奋地使用处理通用逻辑的基类,或者通过使用通常可以消除对 IDisposable 需求的辅助类来实现。

解决 IDisposable 的困难 - 使用辅助类避免直接实现 IDisposable

编写一个包装非托管资源的类是很常见的,这个非托管资源通常是指向某个数据结构的指针。对于这个常见的用例,可以通过微软提供的辅助类获得更高级别的抽象。System.Runtime.InteropServices.SafeHandleSystem.Runtime.InteropServices.CriticalHandle 以及 Microsoft.Win32.SafeHandles 中的类,如果非托管资源可以被视为 IntPtr,则允许编写非常简单的非托管资源包装器。然而,.NET Compact Framework 不支持这些;在该平台上,我建议你自己编写这些极其有用的类的版本。

在可释放设计原则中,0 级类型应始终派生自 SafeHandle(如果目标平台可用)。SafeHandle 及其派生类有特殊的 P/Invoke 支持,这有助于在某些罕见情况下防止资源泄漏。互操作代码应将函数参数和返回类型定义为 SafeHandle(或派生类型)而不是 IntPtrCriticalHandle 类,尽管名字如此,实际上比 SafeHandle 更不安全,通常应避免使用。

SafeWaitHandleWaitHandle 之间的关系是可释放设计原则的一个完美例子:SafeWaitHandle 是 0 级类,而 WaitHandle 是提供普通最终用户 API 的 1 级类。SafeWaitHandle 位于 SafeHandle 层次结构中,将 SafeHandle.ReleaseHandle 实现为对 Win32 CloseHandle 函数的调用;它只关心如何释放资源。相比之下,1 级 WaitHandle 类不在 SafeHandle 层次结构中;其层次结构公开了可等待句柄的完整 API,例如 WaitOne

这意味着在需要编写新的非托管资源包装器时,有四种可能性(按实现难易程度排序):

  1. 已经存在针对该非托管资源的 0 级类型。换句话说,该非托管资源是一个已经被派生自 SafeHandle 的类所覆盖的指针类型。微软已经提供了几个类,包括 SafeFileHandleSafePipeHandleSafeWaitHandle 等。在这种情况下,程序员只需要创建一个新的 1 级类型。
  2. 非托管资源是一个指针类型,但没有已定义的合适 0 级类型。在这种情况下,程序员需要创建两个类,一个 0 级和一个 1 级。
  3. 需要包装的非托管资源是一个简单的指针类型,附带一些额外信息(如次要指针或整数“上下文”值)。在这种情况下,程序员也必须创建两个类,但 0 级类型的实现细节更复杂。
  4. 非托管资源根本不是指针类型。在这种情况下,程序员必须创建两个类,并且两者的实现细节都复杂得多。

请注意,在创建 1 级类型的层次结构时,通常的做法是在(可能是 abstract 的)基类 1 级类型中声明一个 protected 属性,并且此字段应具有相关 0 级类型的类型和名称。例如,1 级抽象基类 WaitHandle 为可等待句柄建立了 1 级层次结构,它有一个名为 SafeWaitHandleprotected 属性,类型为 SafeWaitHandle

包装非托管资源 - 使用现有的 0 级类型(简单情况)

要定义一个使用 0 级类型的新 1 级类型,如果可能,请扩展现有的 1 级层次结构。

使用现有 0 级(派生自 SafeHandle)类型的例子是 ManualResetTimer(命名以匹配现有的 ManualResetEvent)。在 .NET 框架提供的众多计时器中,他们没有包含一个基于 WaitHandle 的计时器,该计时器在计时器到期时会被置位。这种“可等待计时器”,如 SDK 所称,常用于异步程序。为简单起见,此示例不支持周期性计时器或带有异步回调函数的计时器。

请注意,ManualResetTimer 派生自 WaitHandle(1 级层次结构),因为 0 级的 SafeWaitHandle 已经正确地释放了非托管资源。由于已经存在的 0 级/1 级类层次结构划分,实现 ManualResetTimer 非常直接。

[SecurityPermission(SecurityAction.LinkDemand, 
   Flags = SecurityPermissionFlag.UnmanagedCode)]
internal static partial class NativeMethods
{
    [DllImport("kernel32.dll", EntryPoint = "CreateWaitableTimer", 
        CharSet = CharSet.Auto, BestFitMapping = false,
        ThrowOnUnmappableChar = true, SetLastError = true), 
     SuppressUnmanagedCodeSecurity]
    private static extern SafeWaitHandle DoCreateWaitableTimer(IntPtr lpTimerAttributes,
        [MarshalAs(UnmanagedType.Bool)] bool bManualReset, string lpTimerName);
    internal static SafeWaitHandle CreateWaitableTimer(IntPtr lpTimerAttributes, 
             bool bManualReset, string lpTimerName)
    {
        SafeWaitHandle ret = DoCreateWaitableTimer(lpTimerAttributes, 
                             bManualReset, lpTimerName);
        if (ret.IsInvalid)
            Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
        return ret;
    }

    [DllImport("kernel32.dll", EntryPoint = "CancelWaitableTimer", 
               SetLastError = true), 
     SuppressUnmanagedCodeSecurity]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool DoCancelWaitableTimer(SafeWaitHandle hTimer);
    internal static void CancelWaitableTimer(SafeWaitHandle hTimer)
    {
        if (!DoCancelWaitableTimer(hTimer))
            Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
    }

    [DllImport("kernel32.dll", EntryPoint = "SetWaitableTimer", 
               SetLastError = true), SuppressUnmanagedCodeSecurity]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool DoSetWaitableTimer(SafeWaitHandle hTimer, 
            [In] ref long pDueTime, int lPeriod,
            IntPtr pfnCompletionRoutine, IntPtr lpArgToCompletionRoutine, 
            [MarshalAs(UnmanagedType.Bool)] bool fResume);
    internal static void SetWaitableTimer(SafeWaitHandle hTimer, long pDueTime, 
             int lPeriod, IntPtr pfnCompletionRoutine,
             IntPtr lpArgToCompletionRoutine, bool fResume)
    {
        if (!DoSetWaitableTimer(hTimer, ref pDueTime, lPeriod, 
                 pfnCompletionRoutine, lpArgToCompletionRoutine, fResume))
            Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
    }
}

/// <summary>
/// A manual-reset, non-periodic, waitable timer.
/// </summary>
public sealed class ManualResetTimer : WaitHandle
{
    /// <summary>
    /// Creates a new <see cref="ManualResetTimer"/>.
    /// </summary>
    public ManualResetTimer()
    {
        SafeWaitHandle = 
          NativeMethods.CreateWaitableTimer(IntPtr.Zero, true, null);
    }

    /// <summary>
    /// Cancels the timer. This does not change the signalled state.
    /// </summary>
    public void Cancel()
    {
        NativeMethods.CancelWaitableTimer(SafeWaitHandle);
    }

    /// <summary>
    /// Sets the timer to signal at the specified time,
    /// which may be an absolute time or a relative (negative) time.
    /// </summary>
    /// <param name="dueTime">The time, interpreted
    /// as a <see cref="FILETIME"/> value</param>
    private void Set(long dueTime)
    {
        NativeMethods.SetWaitableTimer(SafeWaitHandle, dueTime, 0, 
                                       IntPtr.Zero, IntPtr.Zero, false);
    }

    /// <summary>
    /// Sets the timer to signal at the specified time. Resets the signalled state.
    /// </summary>
    /// <param name="when">The time that this
    /// timer should become signaled.</param>
    public void Set(DateTime when) { Set(when.ToFileTimeUtc()); }

    /// <summary>
    /// Sets the timer to signal after a time span. Resets the signaled state.
    /// </summary>
    /// <param name="when">The time span after
    /// which the timer will become signaled.</param>
    public void Set(TimeSpan when) { Set(-when.Ticks); }
}

请注意以下几点:

  • 始终使用 SafeHandle 或其派生类型作为互操作函数的参数和返回值。例如,此示例代码使用 SafeWaitHandle 而不是 IntPtr。这可以防止在线程意外中止时发生资源泄漏。
  • 由于 1 级层次结构已经存在,ManualResetTimer 不必处理释放问题,即使是其托管资源也不必。这一切都由 WaitHandle 基类处理。

包装非托管资源 - 为指针定义 0 级类型(中级情况)

在许多情况下,并不存在合适的 0 级类型。这些情况需要定义一个 0 级类型,然后定义一个 1 级类型(或类型层次结构)。定义 0 级类型比定义 1 级类型更复杂。

定义简单 0 级类型的例子是一个窗口站对象。这是由单个 IntPtr 句柄表示的众多资源之一。首先,必须定义 0 级类型:

[SecurityPermission(SecurityAction.LinkDemand, 
   Flags = SecurityPermissionFlag.UnmanagedCode)]
internal static partial class NativeMethods
{
    [DllImport("user32.dll", EntryPoint = "CloseWindowStation", 
      SetLastError = true), SuppressUnmanagedCodeSecurity]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static extern bool CloseWindowStation(IntPtr hWinSta);
}

/// <summary>
/// Level 0 type for window station handles.
/// </summary>
public sealed class SafeWindowStationHandle : SafeHandle
{
    public SafeWindowStationHandle() : base(IntPtr.Zero, true) { }
    public override bool IsInvalid
    {
        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        [PrePrepareMethod]
        get { return (handle == IntPtr.Zero); }
    }

    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
    [PrePrepareMethod]
    protected override bool ReleaseHandle()
    {
        return NativeMethods.CloseWindowStation(handle);
    }
}

代码注释:

  • 非托管资源释放函数(在本例中是 NativeMethods.CloseWindowStation)接受一个常规的 IntPtr(而不是 SafeWindowStationHandle)来释放资源。
  • 由于 SafeHandle 派生自 CriticalFinalizerObjectIsInvalidReleaseHandle 都可以在约束执行区域(Constrained Execution Region)中运行,这意味着:
    • 它们不能分配对象、装箱值、获取锁,或通过委托、函数指针或反射调用方法。
    • 它们应该用 ReliabilityContractAttributePrePrepareMethodAttribute 进行修饰。
  • IsInvalidReleaseHandle 都可能在系统关闭期间从终结器运行,因此它们绝对不能访问任何托管对象。

由于 0 级类型的 ReleaseHandle 仅 P/Invoke 其资源清理函数并返回,因此约束执行区域和终结器的约束在实践中并不麻烦。唯一的别扭之处在于需要额外的特性。

一旦 0 级类型完成,就可以定义 1 级类型:

[SecurityPermission(SecurityAction.LinkDemand, 
      Flags = SecurityPermissionFlag.UnmanagedCode)]
internal static partial class NativeMethods
{
    [DllImport("user32.dll", EntryPoint = "OpenWindowStation", 
        CharSet = CharSet.Auto, BestFitMapping = false,
        ThrowOnUnmappableChar = true, SetLastError = true), 
     SuppressUnmanagedCodeSecurity]
    private static extern SafeWindowStationHandle 
            DoOpenWindowStation(string lpszWinSta,
            [MarshalAs(UnmanagedType.Bool)] bool fInherit, 
            uint dwDesiredAccess);
    internal static SafeWindowStationHandle 
             OpenWindowStation(string lpszWinSta, 
             bool fInherit, uint dwDesiredAccess)
    {
        SafeWindowStationHandle ret = 
          DoOpenWindowStation(lpszWinSta, fInherit, dwDesiredAccess);
        if (ret.IsInvalid)
            Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
        return ret;
    }

    [DllImport("user32.dll", EntryPoint = "SetProcessWindowStation", 
         SetLastError = true), SuppressUnmanagedCodeSecurity]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool 
            DoSetProcessWindowStation(SafeWindowStationHandle hWinSta);
    internal static void SetProcessWindowStation(SafeWindowStationHandle hWinSta)
    {
        if (!DoSetProcessWindowStation(hWinSta))
            Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
    }
}

/// <summary>
/// A window station.
/// </summary>
public sealed class WindowStation : IDisposable
{
    /// <summary>
    /// The underlying window station handle.
    /// </summary>
    private SafeWindowStationHandle SafeWindowStationHandle;

    /// <summary>
    /// Implementation of IDisposable: closes the underlying window station handle.
    /// </summary>
    public void Dispose()
    {
        SafeWindowStationHandle.Dispose();
    }

    /// <summary>
    /// Opens an existing window station.
    /// </summary>
    public WindowStation(string name)
    {
        // ("0x37F" is WINSTA_ALL_ACCESS)
        SafeWindowStationHandle = NativeMethods.OpenWindowStation(name, false, 0x37F);
    }

    /// <summary>
    /// Sets this window station as the active one for this process.
    /// </summary>
    public void SetAsActive()
    {
        NativeMethods.SetProcessWindowStation(SafeWindowStationHandle);
    }
}

注释

  • 非托管的本地方法现在都使用 SafeWindowStationHandle 作为其返回值和参数,而不是 IntPtr。只有资源释放函数传递的是 IntPtr
  • 为简单起见,NativeMethods.OpenWindowStation 接受一个 uint 作为其期望的访问掩码,而不是一个合适的枚举。在生产代码中应使用真正的枚举。
  • IDisposable.Dispose 的实现很直接:释放底层的句柄。
  • 不需要终结器,因为 SafeWindowStationHandle 有自己的终结器(继承自 SafeHandle),它会释放底层的句柄。

由于窗口站是一个简单的例子,只有一个 1 级类而不是 1 级类的层次结构。要定义一个层次结构,应使用以下代码模式:

/// <summary>
/// A base class for window station types.
/// </summary>
public abstract class WindowStationBase : IDisposable
{
    /// <summary>
    /// The underlying window station handle.
    /// </summary>
    protected SafeWindowStationHandle SafeWindowStationHandle { get; set; }

    /// <summary>
    /// Implementation of IDisposable: closes the underlying window station handle.
    /// </summary>
    public void Dispose()
    {
        DisposeManagedResources();
    }

    /// <summary>
    /// Disposes managed resources in this class and derived classes.
    /// When overriding this in a derived class,
    /// be sure to call base.DisposeManagedResources()
    /// </summary>
    protected virtual void DisposeManagedResources()
    {
        SafeWindowStationHandle.Dispose();
    }
}

/// <summary>
/// A window station.
/// </summary>
public sealed class WindowStation : WindowStationBase
{
    /// <summary>
    /// Opens an existing window station.
    /// </summary>
    public WindowStation(string name)
    {
        // ("0x37F" is WINSTA_ALL_ACCESS)
        SafeWindowStationHandle = 
           NativeMethods.OpenWindowStation(name, false, 0x37F);
    }

    /// <summary>
    /// Sets this window station as the active one for this process.
    /// </summary>
    public void SetAsActive()
    {
        NativeMethods.SetProcessWindowStation(SafeWindowStationHandle);
    }
}

注释

  • SafeWindowStationHandle现在是一个protected属性。这应该由派生类设置,通常在它们的构造函数中。请注意,这也可能是一个public属性(例如,微软选择将WaitHandle.SafeWaitHandle设为public);然而,我认为protected是更好的选择。
  • 在基类中实现 IDisposable 时,我假设采用可释放设计原则,而不是使用微软的 IDisposable 代码模式。因此:
    • 派生自 WindowStationBase 的类型不能直接拥有非托管资源,即它们必须是 1 级类型。请注意,它们可以拥有 0 级类型,而 0 级类型可以拥有非托管资源;只是它们自己不能 0 级类型。
    • WindowStationBase(或任何派生类型)不需要有终结器。实现微软的 IDisposable 代码模式需要一个终结器。
    • 我选择将资源释放函数命名为 DisposeManagedResources,这在逻辑上等同于微软 IDisposable 代码模式中的 Dispose(true)

包装非托管资源 - 为带上下文数据的指针定义 0 级类型(高级情况)

有时,非托管 API 需要额外的上下文信息才能释放资源。这需要一个附加了一些额外信息的 0 级类型,而这总是需要更复杂的互操作代码。

定义高级 0 级类型的例子是在另一个进程的上下文中分配内存。另一个进程的句柄需要与分配的内存关联,并且需要传递给释放函数。首先,是 0 级类型:

[SecurityPermission(SecurityAction.LinkDemand, 
  Flags = SecurityPermissionFlag.UnmanagedCode)]
internal static partial class NativeMethods
{
    [DllImport("kernel32.dll", EntryPoint = "VirtualFreeEx", 
      SetLastError = true), SuppressUnmanagedCodeSecurity]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static extern bool VirtualFreeEx(SafeHandle hProcess, 
             IntPtr lpAddress, UIntPtr dwSize, uint dwFreeType);
}

/// <summary>
/// Level 0 type for memory allocated in another process.
/// </summary>
public sealed class SafeRemoteMemoryHandle : SafeHandle
{
    public SafeHandle SafeProcessHandle
    {
        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        [PrePrepareMethod]
        get;

        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        [PrePrepareMethod]
        private set;
    }
    private bool ReleaseSafeProcessHandle;

    public SafeRemoteMemoryHandle() : base(IntPtr.Zero, true) { }

    public override bool IsInvalid
    {
        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        [PrePrepareMethod]
        get { return (handle == IntPtr.Zero); }
    }

    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
    [PrePrepareMethod]
    protected override bool ReleaseHandle()
    {
        // (0x8000 == MEM_RELEASE)
        bool ret = NativeMethods.VirtualFreeEx(SafeProcessHandle, 
                             handle, UIntPtr.Zero, 0x8000);
        if (ReleaseSafeProcessHandle)
            SafeProcessHandle.DangerousRelease();
        return ret;
    }

    /// <summary>
    /// Overwrites the handle value (without releasing it).
    /// This should only be called from functions acting as constructors.
    /// </summary>
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    [PrePrepareMethod]
    internal void SetHandle
	(IntPtr handle_, SafeHandle safeProcessHandle, ref bool success)
    {
        handle = handle_;
        SafeProcessHandle = safeProcessHandle;
        SafeProcessHandle.DangerousAddRef(ref ReleaseSafeProcessHandle);
        success = ReleaseSafeProcessHandle;
    }
}

注释

  • 这与之前定义的 0 级类型非常相似;只是这个类还保留了对远程进程的 SafeHandle 引用,该引用必须传递给 VirtualFreeEx
  • 一个 0 级类型可以包含对另一个 0 级类型的引用(在这个例子中,SafeRemoteMemoryHandle 有一个 SafeHandle 类型的字段)。然而,它必须显式控制该字段的引用计数,这需要一个额外的布尔字段(ReleaseSafeProcessHandle)。
  • 进程句柄被持有为 SafeHandle,而不是 IntPtr。这是因为 SafeHandle 内部实现了引用计数以防止过早释放。这在作为 SafeRemoteMemoryHandle 中的字段和传递给 VirtualFreeEx 时都很有用。
  • 由于 SafeProcessHandle 可能在 CERs 期间被访问,其访问器需要 ReliabilityContractPrePrepareMethod 特性。
  • 还有一个额外的方法,SafeRemoteMemoryHandle.SetHandle,它被设计为在一个约束执行区域内执行,因此它可以原子地同时设置远程进程句柄和非托管句柄。
  • 再次,为简单起见,省略了适当的枚举。
  • 此外,更恰当的处理远程进程句柄需要定义一个 SafeProcessHandle,并在此示例中用它代替 SafeHandle。此示例具有完全正确的行为,但没有提供完整的类型安全。

1 级类型揭示了创建 SafeRemoteMemoryHandle 对象所需的额外复杂性:

[SecurityPermission(SecurityAction.LinkDemand, 
  Flags = SecurityPermissionFlag.UnmanagedCode)]
internal static partial class NativeMethods
{
    [DllImport("kernel32.dll", EntryPoint = "VirtualAllocEx", 
               SetLastError = true), SuppressUnmanagedCodeSecurity]
    private static extern IntPtr DoVirtualAllocEx(SafeHandle hProcess, 
            IntPtr lpAddress, UIntPtr dwSize,
            uint flAllocationType, uint flProtect);
    internal static SafeRemoteMemoryHandle VirtualAllocEx(SafeHandle hProcess, 
             IntPtr lpAddress, UIntPtr dwSize,
             uint flAllocationType, uint flProtect)
    {
        SafeRemoteMemoryHandle ret = new SafeRemoteMemoryHandle();
        bool success = false;

        // Atomically get the native handle
        // and assign it into our return object.
        RuntimeHelpers.PrepareConstrainedRegions();
        try { }
        finally
        {
            IntPtr address = DoVirtualAllocEx(hProcess, lpAddress, 
                             dwSize, flAllocationType, flProtect);
            if (address != IntPtr.Zero)
                ret.SetHandle(address, hProcess, ref success);
            if (!success)
                ret.Dispose();
        }

        // Do error handling after the CER
        if (!success)
            throw new Exception("Failed to set handle value");
        if (ret.IsInvalid)
            Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
        return ret;
    }

    [DllImport("kernel32.dll", EntryPoint = "WriteProcessMemory", 
               SetLastError = true), SuppressUnmanagedCodeSecurity]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool DoWriteProcessMemory(SafeHandle hProcess, 
            SafeRemoteMemoryHandle lpBaseAddress,
            IntPtr lpBuffer, UIntPtr nSize, out UIntPtr lpNumberOfBytesWritten);
    internal static void WriteProcessMemory(SafeRemoteMemoryHandle RemoteMemory, 
                         IntPtr lpBuffer, UIntPtr nSize)
    {
        UIntPtr NumberOfBytesWritten;
        if (!DoWriteProcessMemory(RemoteMemory.SafeProcessHandle, RemoteMemory, 
                                  lpBuffer, nSize, out NumberOfBytesWritten))
            Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
        if (nSize != NumberOfBytesWritten)
            throw new Exception
		("WriteProcessMemory: Failed to write all bytes requested");
    }
}

/// <summary>
/// Memory allocated in another process.
/// </summary>
public sealed class RemoteMemory : IDisposable
{
    /// <summary>
    /// The underlying remote memory handle.
    /// </summary>
    private SafeRemoteMemoryHandle SafeRemoteMemoryHandle;

    /// <summary>
    /// The associated process handle.
    /// </summary>
    public SafeHandle SafeProcessHandle 
           { get { return SafeRemoteMemoryHandle.SafeProcessHandle; } }

    /// <summary>
    /// Implementation of IDisposable: closes the underlying remote memory handle.
    /// </summary>
    public void Dispose()
    {
        SafeRemoteMemoryHandle.Dispose();
    }

    /// <summary>
    /// Allocates memory from another process.
    /// </summary>
    public RemoteMemory(SafeHandle process, UIntPtr size)
    {
        // ("0x3000" is MEM_COMMIT | MEM_RESERVE)
        // ("0x04" is PAGE_READWRITE)
        SafeRemoteMemoryHandle = 
          NativeMethods.VirtualAllocEx(process, IntPtr.Zero, size, 0x3000, 0x04);
    }

    /// <summary>
    /// Writes to memory in another process.
    /// Note: at least <paramref name="size"/> bytes starting
    /// at <paramref name="buffer"/> must be pinned in memory.
    /// </summary>
    public void Write(IntPtr buffer, UIntPtr size)
    {
        NativeMethods.WriteProcessMemory(SafeRemoteMemoryHandle, buffer, size);
    }
}

注释

  • 首先应该突出的是分配函数复杂了多少。NativeMethods.VirtualAllocEx 被设计为部分在一个显式的约束执行区域内运行。具体来说:
    • 它在 CER 之前进行所有必要的分配。在这个例子中,它只需要分配返回的 SafeRemoteMemoryHandle 对象。
    • RuntimeHelpers.PrepareConstrainedRegions 的调用后跟一个空的 try 块,是将 finally 块声明为显式约束执行区域的方式。有关此方法的更多详细信息,请参阅 MSDN
    • 它在 CER 之后执行错误检查,包括抛出异常(可能会分配内存)。
  • CER 提供了原子执行:它保证从非托管 VirtualAllocEx 返回的 IntPtr 被包装在一个 SafeRemoteMemoryHandle 对象中,即使存在异步异常(例如,如果在一个 CER 中的线程上调用了 Thread.Abort,CLR 将等待 CER 完成后才异步引发 ThreadAbortException)。
  • 在更简单的例子中,CER 不是必需的,因为 SafeHandle 在从非托管函数返回时会受到特殊处理:返回值(实际上是一个 IntPtr)被用来原子地构造一个新的 SafeHandle。换句话说,CLR 自动支持 SafeHandle 的这种行为,但现在我们必须使用 CER 来强制实现相同的行为。
  • 另一个重要注意事项是,互操作代码应继续引用 0 级类型(例如,SafeRemoteMemoryHandle),而不仅仅是 IntPtr;这使 SafeHandle 的引用计数保持参与。将上下文数据(例如,SafeHandleSafeProcessHandle)与一个普通的 IntPtr 一起传递将是不正确的。
  • RemoteMemory 1 级类型确实公开了额外的上下文属性(作为 RemoteMemory.SafeProcessHandle)。这不是必需的,但通常很有用。

关于此示例如何简化的说明:

  • 为简单起见,此示例只提供了一个 1 级类,而不是一个类层次结构。有关 1 级层次结构模式的示例,请参阅前一个示例。
  • 再次,进程的 SafeHandle 应该是一个 SafeProcessHandle,并且省略了适当的枚举。
  • 此示例也没有公开一个非常用户友好的 API;它应包括在不同偏移量处的读写,并且应接受字节数组而不是预固定的内存。
  • 不应直接抛出 Exception 类型的异常;这应该是一个更具体的类型。

包装非托管资源 - 为非指针数据定义 0 级类型(困难情况)

有少数非托管 API 的句柄类型不是指针。这些句柄类型中的每一个都可以转换为 IntPtr(如果它们小于或等于 IntPtr 类型)或被视为一个伪 IntPtr 的额外上下文数据。

非指针 0 级类型的例子是本地原子表。在现代程序中使用这个过时的 API 没有真正的理由,但这个例子将说明如何处理这种性质的 API。ATOM 类型是一个无符号的 16 位整数,为了说明目的,该示例被实现了两次:一次将 ushort 扩展为 IntPtr,另一次将 ushort 视为伪 IntPtr 的上下文数据。

首先,是原子表的 0 级类型,将 ushort 非托管句柄值存储在 IntPtr SafeHandle.handle 字段中:

[SecurityPermission(SecurityAction.LinkDemand, 
   Flags = SecurityPermissionFlag.UnmanagedCode)]
internal static partial class NativeMethods
{
    [DllImport("kernel32.dll", EntryPoint = "DeleteAtom", 
               SetLastError = true), SuppressUnmanagedCodeSecurity]
    internal static extern ushort DeleteAtom(ushort nAtom);
}

/// <summary>
/// Level 0 type for local atoms (casting implementation).
/// </summary>
public sealed class SafeAtomHandle : SafeHandle
{
    /// <summary>
    /// Internal unmanaged handle value, translated to the correct type.
    /// </summary>
    public ushort Handle
    {
        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        [PrePrepareMethod]
        get
        {
            return unchecked((ushort)(short)handle);
        }

        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        [PrePrepareMethod]
        internal set
        {
            handle = unchecked((IntPtr)(short)value);
        }
    }

    /// <summary>
    /// Default constructor initializing with an invalid handle value.
    /// </summary>
    public SafeAtomHandle() : base(IntPtr.Zero, true) { }

    /// <summary>
    /// Whether or not the handle is invalid.
    /// </summary>
    public override bool IsInvalid
    {
        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        [PrePrepareMethod]
        get { return (Handle == 0); }
    }

    /// <summary>
    /// Releases the handle.
    /// </summary>
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
    [PrePrepareMethod]
    protected override bool ReleaseHandle()
    {
        return (NativeMethods.DeleteAtom(Handle) == 0);
    }
}

唯一值得注意的区别是增加了 Handle 属性,它提供了对 handle 的访问,并将其视为一个 ushort。请注意属性访问器上 ReliabilityContractPrePrepareMethod 特性的必要性。IsInvalidReleaseHandle 的实现使用 Handle 而不是 handle 以方便实现。

额外的复杂性体现在与 1 级类一起使用的互操作代码中:

[SecurityPermission(SecurityAction.LinkDemand, 
   Flags = SecurityPermissionFlag.UnmanagedCode)]
internal static partial class NativeMethods
{
    [DllImport("kernel32.dll", EntryPoint = "AddAtom", 
        CharSet = CharSet.Auto, BestFitMapping = false,
        ThrowOnUnmappableChar = true, SetLastError = true), 
     SuppressUnmanagedCodeSecurity]
    private static extern ushort DoAddAtom(string lpString);
    internal static SafeAtomHandle AddAtom(string lpString)
    {
        SafeAtomHandle ret = new SafeAtomHandle();

        // Atomically get the native handle
        // and assign it into our return object.
        RuntimeHelpers.PrepareConstrainedRegions();
        try { }
        finally
        {
            ushort atom = DoAddAtom(lpString);
            if (atom != 0)
                ret.Handle = atom;
        }

        // Do error handling after the CER
        if (ret.IsInvalid)
            Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
        return ret;
    }

    [DllImport("kernel32.dll", EntryPoint = "GetAtomName", 
        CharSet = CharSet.Auto, BestFitMapping = false,
        ThrowOnUnmappableChar = true, SetLastError = true), 
     SuppressUnmanagedCodeSecurity]
    private static extern uint DoGetAtomName(ushort nAtom, 
                       StringBuilder lpBuffer, int nSize);
    internal static string GetAtomName(SafeAtomHandle atom)
    {
        // Atom strings have a maximum size of 255 bytes
        StringBuilder sb = new StringBuilder(255);
        uint ret = 0;
        bool success = false;

        // Atomically increment the SafeHandle reference count,
        // call the native function, and decrement the count
        RuntimeHelpers.PrepareConstrainedRegions();
        try { }
        finally
        {
            atom.DangerousAddRef(ref success);
            if (success)
            {
                ret = DoGetAtomName(atom.Handle, sb, 256);
                atom.DangerousRelease();
            }
        }

        // Do error handling after the CER
        if (!success)
            throw new Exception("SafeHandle.DangerousAddRef failed");
        if (ret == 0)
            Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());

        sb.Length = (int)ret;
        return sb.ToString();
    }
}

/// <summary>
/// Atom in the local atom table.
/// </summary>
public sealed class LocalAtom : IDisposable
{
    /// <summary>
    /// The underlying atom handle.
    /// </summary>
    private SafeAtomHandle SafeAtomHandle;

    /// <summary>
    /// Implementation of IDisposable: closes the underlying atom handle.
    /// </summary>
    public void Dispose()
    {
        SafeAtomHandle.Dispose();
    }

    /// <summary>
    /// Adds a string to the atom table, setting this local atom to point to it.
    /// </summary>
    public void Add(string name)
    {
        SafeAtomHandle = NativeMethods.AddAtom(name);
    }

    public string Name
    {
        get
        {
            return NativeMethods.GetAtomName(SafeAtomHandle);
        }
    }
}

这个例子与上一个例子之间的主要区别在于,在每个互操作调用中都需要 CER。来自 SafeHandle 的自动引用计数不再是自动的,所以必须手动完成。每次需要将底层的非托管句柄传递给非托管函数时,都应遵循 NativeMethods.GetAtomName 的例子:

  1. 初始化返回值(在本例中是一个返回缓冲区)和任何错误条件变量。
  2. 使用 CER 来原子地增加 SafeHandle 引用计数,调用非托管函数,并减少 SafeHandle 计数。请注意,增加 SafeHandle 引用计数可能会失败,这应该中止调用。[或者,增加和非托管函数调用可以放在 try 块内,但减少必须保留在 finally 块中。]
  3. 执行所有错误测试:SafeHandle 的增加以及非托管函数的结果都必须被考虑。请记住,在生产代码中不推荐抛出 Exception;应选择更具体的类型。

第二种实现(使用上下文值而不是与 IntPtr 相互转换)可以在转换很尴尬,或者非托管句柄类型不适合单个 IntPtr 字段时选择。通过仅将其赋值为 0(表示无效句柄值)或 -1(表示句柄——包括上下文值——是有效的),可以使 SafeHandle.handle 字段几乎没有意义:

/// <summary>
/// Level 0 type for local atoms (context implementation).
/// </summary>
public sealed class SafeAtomHandle : SafeHandle
{
    public ushort Handle
    {
        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        [PrePrepareMethod]
        get;

        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        [PrePrepareMethod]
        private set;
    }

    /// <summary>
    /// Default constructor initializing with an invalid handle value.
    /// </summary>
    public SafeAtomHandle() : base(IntPtr.Zero, true) { }

    /// <summary>
    /// Whether or not the handle is invalid.
    /// </summary>
    public override bool IsInvalid
    {
        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        [PrePrepareMethod]
        get { return (handle == IntPtr.Zero); }
    }

    /// <summary>
    /// Releases the handle.
    /// </summary>
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
    [PrePrepareMethod]
    protected override bool ReleaseHandle()
    {
        return (NativeMethods.DeleteAtom(Handle) == 0);
    }

    /// <summary>
    /// Overwrites the handle value (without releasing it).
    /// This should only be called from functions acting as constructors.
    /// </summary>
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    [PrePrepareMethod]
    internal void SetHandle(ushort handle_)
    {
        Handle = handle_;
        handle = (IntPtr)(-1);
    }
}

注释

  • Handle 属性现在是一个上下文,与 handle 分开存储。
  • IsInvalid 属性测试 handle,它现在只有 0-1 的值,但 ReleaseHandle 方法为了方便仍然使用 Handle
  • Handle 的 setter 已被 SetHandle 方法替换。在示例中这样做是为了反映这样一个事实:大多数时候使用上下文时,SetHandle 将需要接受多个参数。

示例其余部分唯一需要做的改变是在 NativeMethods.AddAtom 构造函数方法中设置句柄的方式的改变:

ret.Handle = atom;

应改为

ret.SetHandle(atom);

请记住,在实际情况中,SetHandle 会接受多个参数。

摘要

总而言之,优先使用可释放设计原则(Disposable Design Principle)。DDP 将资源管理职责划分为 0 级类型(处理非托管资源)和 1 级类型(仍然是与原生 API 非常相似的小型包装类,但只处理托管资源):

  1. 0 级类型直接包装非托管资源,并且只关心其资源的释放。
    1. 0 级类型要么是 abstract 要么是 sealed
    2. 0 级类型必须被设计为能完全在一个原子执行区域内执行。
      • 对于约束执行区域,这意味着 0 级类型必须派生自 SafeHandle(它派生自 CriticalFinalizerObject)。
      • 对于 finally 块,这意味着 0 级类型必须派生自一个单独定义的 SafeHandle 类型,该类型实现 IDisposable 以显式释放非托管资源(可能在 finally 块的上下文中调用)或从终结器中释放。
    3. 0 级类型的构造函数必须在一个原子执行区域内被调用。
      • 完整的框架互操作对 SafeHandle 返回值的特殊处理被认为是非托管代码(因此具有最强保证的原子执行区域)。
    4. 0 级类型可以引用其他 0 级类型,但只要需要该引用,就必须增加被引用对象的计数。
  2. 1 级类型只处理托管资源。
    1. 1 级类型通常是 sealed 的,除非它们正在为一个 1 级层次结构定义一个基类 1 级类型。
    2. 1 级类型直接派生自 1 级类型或 IDisposable;它们不派生自 CriticalFinalizerObject 或 0 级类型。
    3. 1 级类型可以有 0 级或 1 级类型的字段。
    4. 1 级类型通过调用其每个 0 级和 1 级字段的 Dispose 来实现 IDisposable.Dispose,然后如果适用,则调用 base.Dispose
    5. 1 级类型没有终结器。
    6. 在定义 1 级类型层次结构时,abstract 的根基类应该定义一个 protected 属性,其名称和类型与相关的 0 级类型相同。

使用可释放设计原则(而不是微软的 IDisposable 代码模式)将使软件更可靠、更易于使用。

参考文献与延伸阅读

后记

在未来的文章中,我希望解决 IDisposable 的另一个缺点:缺乏对关闭逻辑的支持;并提供一个(部分的)解决方案。这最初打算作为本文的一部分,但现在已经太长了。我还希望探讨 .NET Compact Framework 的 SafeHandle 替代方案,遗憾的是它不支持 SafeHandle约束执行区域

我要感谢我挚爱的准妻子 Mandy Snell,她耐心地校对了这篇文章。在2008年10月4日,她将正式成为 Mandy Cleary。:) 我还必须声明,我生命中所有美好的事物都来自耶稣基督;他是所有智慧的源泉,我感谢他所有的恩赐。“神赐人智慧、知识和喜乐,是在他眼前为善的人”(传道书 2:26)。

历史

  • 2008-09-27 - 修复了高级示例中的一个错误,重写了 DDP 的摘要,并添加了微软决定不支持引用计数的理由的引用。
  • 2008-09-22 - 添加了参考文献历史部分。
  • 2008-09-21 - 初次发布。
© . All rights reserved.