指针、句柄和资源

目前,术语“托管代码”的概念还不明确,它是指任何在CLR控制下执行的代码。换句话说,它们起初是C#、VB、C++/CLI语言形式的源代码(或者是许多其他语言形式之一的源代码),它们的编译器对写入元数据和IL的高级语言代码进行转换。当然,利用像GC和安全这样的几个托管服务,CLR的JIT随后会将它们转变为其本地对等物。另一方面,非托管代码没有如此长的生命周期。它确实能够以某种结构化容器或者管理基础结构的形式执行—— COM是一个很好的例子—— 尽管这两种形式有很大的区别。撇开对托管代码的好感,有一个事实仍然存在:许多代码是用非托管技术,如C++、Win32和COM,编写的并且它们仍然存在着,生存着,还有很好的口碑。

为了将一个完整的应用程序从非托管代码转为托管代码,在一个单块集成电路上花费数月时间(或者数年时间)改写项目不是一个经济的建议。这些通常都是那些大型的昂贵项目,据说它们在经过数月的艰苦工作,花费巨大的检查后以失败告终,并且在早期就已经出现了预示项目可能失败的警告信号。是的,以往的代码确实是过时的,也许难于扩展,而且在某些情况下需要特定的专家来维护。但是这样的代码也许已经经历了多年的错误纠正和维护。也就是说,它是成熟的代码。盲目的放弃代码而不利用这些现成的财富是公司的失误。

此外,在一些应用程序中,对于某些组件更适宜使用非托管代码,尤其是那些有内存管理需要的组件。例如,多数高端游戏需要非常严格的管理内存以确保帧速率;由于完全是数量巨大的映射、减法和巨量内存计算及操作,许多科学应用程序同样必须使用原始内存段。

但是,与此同时,同样是这些程序的其他组件将从托管代码中获益。游戏有大量的AI和字符脚本组件;那种逻辑风格的作者可能并不关心内存管理,因此一个执行垃圾回收的环境和一个托管的脚本语言将使他们的操作更加简单。同样地,一个科学应用程序可能使用UI来实现数据可视化;后台中复杂的计算也许使用非托管代码,而Windows Presentation Foundation、WinForms或者ASP.NET可以用来开发UI并输入组件(它们肯定比MFC好)。

幸运的是,.NET Framework允许和C++、Win32和COM之间具有双向互操作性。这就是说可以继续使用这些现有的有价值的资产,并仍然投资未来,建立坚实的托管执行环境。您可能惊讶于运行库所支持的集成的程度。实际上,CLR的设计人员和建设人员早已知道允许已有的自定义代码能够继续运行是非常重要的。Web服务是目前另一个与老式代码进行互操作的技术,但本书不讨论这个策略。本章简单介绍一些工具、技术以及利用在CLR和.NET Framework中找到的大量非托管互操作性时所需的必要技术。

11.1  指针、句柄和资源

不考虑所使用的传统资源或者非托管技术,仍要面对一系列的挑战。本节介绍这些主题并简要地提及一些有帮助的提示以及思考问题的有用的方法。多数叙述详细的“如何”风格的介绍可以在本章稍后看到。

11.1.1  “互操作性”定义

互操作性是一个简单的概念:通过异构技术调用代码以及和程序或库共享数据的能力。这是一个公认的广泛定义。它实际上是什么意思呢?作为一种解释,考虑已经在非托管C++中编写了一个大型的Win32 DLL。换句话说,它使用C++语言,依靠Win32 DLL中提供的函数,并且作为标准的C++ DLL被编译为Win32PE/COFF文件格式。这个代码可能需要多年时间的开发并且需要更长时间的维护。如果临时需要编写一个新的业务组件,您会怎么办?扩展已有的代码库,使用和以前一样的技术?或者是否尝试逐步采用.NET Framework?

结果是,在这种情况下有几个选择,首先是重新用VC++编译,目标是CLR平台,在这种情况下可以将托管和非托管函数加入到现有的代码库中—— 仍然使用C++语法—— 逐步有效地采用托管代码。作为选择,可以用C#编写一个新的托管DLL,然后连接到已有的C++ DLL。第一个选择谈到了新VC++的能力(如,1.x中的MC++和2.0中的C++/CLI),即语言中内嵌了非托管和托管代码互操作性。第二个情况是互操作性的另一个形式,尽管它的结果是更易于移动的部分:两个分离的DLL,其中一个DLL知道如何调用另外一个DLL。这经常是所要维持的更清晰的体系结构。

互操作性很容易理解,但是在实践中要理解它是个挑战。本节介绍一些将要遇到的普通的概念,但是这些概念决不是互操作性的扩充。如果您的目标是完全避免使用传统代码—— 实际上,有时这只是个选择,例如,对于购买的组件、丢失的源代码、或者有一点失误的构建基础结构来说,重建都是耗资巨大的—— 它通常会成为一个令人沮丧的任务。

11.1.2  CTS中的本地指针(IntPtr)

IntPtr类(在IL中是native int)和与其联系紧密的UIntPtr(在IL中是unsigned native int)是位于一个非托管指针或者句柄顶部的瘦包装器—— 在内部存储时表示为void*。IntPtr完成下面的主要目标:

●       指针的大小由程序员抽象出来。实际的底层指针大小是运行时平台上的本地指针大小。这就是说,32位平台(x86)上是32位(DWORD),64位平台(AMD-64、IA-64)上是64位(四元组WORD)。正如非托管指针的情况,当升级到一个新的平台时,可以获得的优点是无偿得到更大的可分配地址的虚拟内存空间。访问静态属性IntPtr.Size可告诉您运行时IntPtr的大小。

●       使用IntPtr所具有的特别类型的系统支持,它使托管代码可以分发指针而无需接收方声明自身是unsafe。解包IntPtr以访问原始指针值作为托管到非托管迁移的一部分自动完成。如果代码要使用void*,它将立刻被编译器和运行时标记为不安全的,这需要在CAS相关的执行上下文中有一定级别的信任。IntPtr是轻量级的包装器,因此不需要这种信任。

IntPtr经常用来表示一个进程的虚拟内存地址空间的偏移。对于使用像C++编写的非托管的代码,这是最普遍的方法,使用它在同一个进程中共享对象。当一个指针被取消引用时,它只是一个整型数值,允许操作存储在内存中该地址的位。遗憾的是,手工使用IntPtr就像在非托管情况下使用指针一样;也就是说,需要考虑内存泄漏、悬挂指针、再循环等问题。SafeHandle有助于隐藏许多这些令人厌恶的细节。SafeHandle对于shared_ptr<void*>相当于IntPtr对于void*。

下面的示例说明了对通过一个IntPtr传递的底层指针的原始操作:

using System;

class Program

{

unsafe static void Main()

{

int a = 10;

IntPtr ip = new IntPtr(&a);

SetByte(ip, 1, 1);

}

static unsafe void SetByte(IntPtr base, int offset, byte value)

{

if (base == IntPtr.Zero)

throw new ArgumentOutOfRangeException();

byte* pTarget = (byte*)base.ToPointer() + offset;

*pTarget = value;

}

}

这是非常有用的,这里有一些有趣的内容。利用C#的不安全构造,可以使用取地址运算符获得栈中一个值的地址,例如&a。然后将它传递给IntPtr的构造函数,该函数允许输入类型为void *。然后调用自定义函数,在以指针为基础的一个偏移上指定特定的字节,使用IntPtr.ToPointer函数获取并使用原始指针。

作为选择,一个IntPtr可以是一个OS句柄。例如,P/Invoke签名用IntPtr而不是HANDLE。术语“句柄”实际比听起来要复杂。在Windows中,这只是用来对每个进程的句柄表进行索引的整数,其中每个句柄引用一个OS托管的资源。例如,每个内核对象(例如,进程、线程、互斥体)通过它的句柄而被访问,然后传递给操作它的Win32函数。例如,一个文件对象在打开文件时也被分配独特的句柄。所有进程中的代码通过这个句柄使用Win32 I/O函数。当客户端使用一个资源完毕时,它们就关闭句柄。这指示OS放弃那些与句柄联系的资源(例如,解除文件的写访问锁)。

1. 句柄循环

结果是,IntPtr容易受到名为“句柄循环”安全问题的影响。要了解一个句柄循环是如何形成攻击的,就需要非常熟悉线程是如何发生竞争的。在第10章即关于线程化的章节中,详细讨论了竞争条件。这里的技巧是使多个线程同时访问一个对象,该对象中封装了一个句柄。一个线程试图得到托管包装器类来关闭句柄,与此同时,另一个线程初始化实例上的一个操作,该实例试图使用句柄,例如通过将它传递给Win32函数。

因为IntPtr不进行任何引用计数,如果一个线程申明它使用IntPtr完成并且关闭句柄(例如,通过CloseHandle关闭),另一个线程将会进入并在调用CloseHandle之前加载一个IntPtr到它的栈中。然后,已经拥有IntPtr在其栈中的线程将在此时使用一个悬挂句柄,而试图使用这个句柄的后继操作在最好的情况下将看到不可预知的行为,而最差将会看到一个安全漏洞。

考虑一个例子:

using System;

class MyFile : IDisposable

{

private IntPtr hFileHandle;

public MyFile(string filename)

{

hFileHandle = OpenFile(filename, out ofStr, OF_READWRITE);

}

~MyFile()

{

Dispose();

}

public void Dispose()

{

if (hFileHandle != IntPtr.Zero)

CloseHandle(hFileHandle);

hFileHandle = IntPtr.Zero;

GC.SuppressFinalize(this);

}

public int ReadBytes(byte[] buffer)

{

// First, ensure the file's not closed

IntPtr hFile = hFileHandle;

if (hFile == IntPtr.Zero)

throw new ObjectDisposedException();

uint read;

if (!ReadFile(hFile, buffer,

buffer.Length, out read, IntPtr.Zero))

throw new Exception("Error " + Marshal.GetLastWin32Error());

return read;

}

private const OF_READWRITE = 0x00000002;

/* P/Invoke signatures for these functions omitted for brevity:

Kernel32!OpenFile

Kernel32!ReadHandle

Kernel32!CloseHandle

*/

}

所有需要做的是拥有一种情况,在这种情况下,某个人正在调用Dispose,同时另一个线程在同一个实例上调用ReadBytes。由此可能产生的结果是:(1)ReadBytes开始运行,为栈中的hFileHandle加载值(记住:这是一个值类型);(2)调度Dispose的执行,或者抢占ReadBytes(在一个单处理器上),或者也许并行运行(在多处理器上);(3)完全执行Dispose,关闭句柄并设定hFileHandle为IntPtr.Zero;(4)在ReadBytes的栈中仍然存在原来的值并将它传递给ReadFile!

这是研究竞争的普遍性以及检测难度的性质的很好案例。

注意:

这些问题和将在非托管代码中遇到的问题是相似的。悬挂指针已经不是新事物,但是托管代码应该可以防止不再出现这些问题!结果发展了新的抽象以帮助解决这个问题,但是深刻理解指针和句柄是如何运作的,对于那些进入非托管互操作性的世界探险的人而言仍然是重要的。

实际上,上面提到的情况可以成为灾难。如果Windows曾经使用和之前的IntPtr一样的整型数值(这种情况经常出现),恶意代码就能看到进程中其他人所拥有的资源。这种情况发生的可能性比您能想到的还大。在这个例子中,认为自己仍然在使用原来句柄的代码的人也许试图在新的句柄上执行操作。例如,它可以使程序访问一个文件句柄,该文件句柄由机器上的另一个程序打开,或者它也可以使程序在其他人的共享数据上随意写入(可能是破坏数据)。

这些是否听起来令人很惊慌,确实是如此。CAS将被暗中完全破坏。Windows内核对象ACL也许有助于捕获这个问题—— 假设用它们来打开句柄(详见第9章)——  但是很少有程序正确使用ACL,尤其是在托管代码中(其中的重要原因是2.0版本以前对System.IO不完善的支持)。参阅下面描述的SafeHandle类,了解具体如何解决这个问题。已经给出了提示(引用计数)。

2. 非指针用法

事实证明IntPtr是存储字段数据的一个便利机制。字段数据随着程序所处平台的内存地址空间功能的增长而自然增长。例如,CLR上的数组使用IntPtr作为它们的内部长度字段,因为一个数组的空间随着可设定地址的内存空间的增加而按比例自然增长。当这种情况允许数组保存的元素多于足够的数量时,理想中想要的是这样一种数据类型,它能够存储sizeof(IntPtr)或者 sizeof(<element_type>)字节(假设计算机的RAM只保存了数组中的数据)—— 但是IntPtr的大小是这个计算的主导因素,因此比Int32更精确。

11.1.3  内存和资源管理

当一个托管对象坚持使用非托管资源时,一旦它使用完资源,就必须显式将其释放。否则,应用程序将会发生内存泄漏或者资源句柄泄漏。除非编写与非托管代码互操作的库或者应用程序,否则很少需要考虑这些细节。必须达到的最远目标就是记住要调用Dispose(甚至当没有它时,一个类型的Finalize方法通常会为您进行清除工作)。

对于进程范围内的泄漏,经常会观察到一个不断增长的工作集或者句柄计数,它们最终会导致内存不足的状况。另一种可能的结果是,程序的性能将降低,逐渐地变成死机(例如,由于分页而造成磁盘系统颠簸),用户将会注意到这种现象,并会遇到Task Manager中的End Task。一旦进程被关闭,OS可以回收内存。对于系统范围的资源泄漏,情况就不这么简单了。OS不能在进程关闭时回收这样的资源。资源可以被孤立,可以干扰机器上其他的程序,或者内存的使用会泄漏,在一些情况中需要用户重启机器。显然,无论如何都要避免这些情况。

注意:

对ASP.NET的设计使它可以忍受这种内存泄漏。多数完善的主机试图以它们自己特定的方式保持弹性。当然它们各自的策略是不同的。ASP.NET重复利用内存泄漏的进程。它为内存的使用提供可配置的限度;如果进程超出了这种限度,则主机关机并重启工作者进程。另一方面,SQL Server极力地试图以防止资源泄漏的方式关闭代码。关键终结—— 这是稍后要讨论的主题——  因此被开发以协助它达到这个目的。

1. 示例:使用非托管内存

首先,看一个关于这个问题的简单的例子。开始的介绍很简单,然后将逐步地增加它的复杂性。将从非常容易泄漏的代码很快进入到能够容忍泄漏的代码。想象您正在为一个函数的生命周期分配一些非托管内存块:

void MyFunction

{

IntPtr ptr = Marshal.AllocHGlobal(1024);

// Do something with 'ptr'...

Marshal.FreeHGlobal(ptr);

}

在System.Runtime.InteropServices命名空间中可以找到Marshal类。AllocHGlobal函数在非托管堆上的当前进程中分配一块非托管内存块,然后返回一个指向它的IntPtr指针。这是逻辑上malloc的等价物。与malloc很相似,除非在返回的地址处显式地释放内存,否则新分配的块会保持被使用状态并永远不会被回收。释放是通过FreeHGlobal方法实现的。

遗憾的是,这段代码编写地已经非常失败(也许除了忘记FreeHGlobal的情况下)。如果在分配和释放之间运行的代码会抛出一个异常,那么将永远不会释放内存块。除此以外,一个异步异常(像ThreadAbortException异常)会在任何地方发生,包括发生在对AllocHGlobal的调用时和存储变量到ptr栈变量中。如果MyFunction的调用者捕获了异常并决定继续执行程序,那么这就遇到了一个泄漏!IntPtr完全丢失,并且没有任何用来解除内存分配的异常分支。直到关闭进程它才会消失。

如果在程序中对像MyFunction的代码有很多调用,则应该尽快结束用尽用户机器上所有可得的内存。或者,在ASP.NET应用程序中,将更加频繁地触发一个回收。

2. 使用Try/Finally清理

部分的解决办法是非常容易的。只需用try/finally块保证内存的释放:

void MyFunction

{

IntPtr ptr = Marshal.AllocHGlobal(1024);

try

{

// Do something with 'ptr'...

}

finally

{

if (ptr != IntPtr.Zero)

{

Marshal.FreeHGlobal(ptr);

ptr = IntPtr.Zero;

}

}

}

注意,这里是在释放ptr后将其设置为IntPtr.Zero的。这当然是一个自由决定的行为。但是它有助于可调试性;如果偶然传递指针给其他一些函数,那么任何解除引用的尝试将产生访问失败。访问失败(也称为AV)通常比内存崩溃更易调试。

(上面的模式对简短提到的异步异常原因不是简单易懂的。即将看到如何修正它。)

3. 对象生命周期不等于资源生命周期

在Framework中到处都是资源。OS句柄可以用来打开文件、数据库连接、GUI元素等。许多您钟爱的系统类型封装了句柄并对作为用户的您隐藏了这些句柄。例如,每个FileStream含有一个文件句柄,SqlConnection依靠数据库连接而连接到SQL Server数据库,而Controls经常包含指向UI元素数据结构的HWND。这种封装类型提供了便于使用的IDisposable的实现。用C#或者VB的using语句封装这样的类型可使清除更加便利:

using (FileStream fs = /*...*/)

{

// Do something with 'fs'...

}

// 'fs' was automatically cleaned up at the end of the block

现在考虑当一个托管对象拥有这样的资源的生命周期时会发生什么。理想情况下,当用户确切地知道已经使用完资源时,它将为用户提供一种确定性的清除资源的方法。但是在最坏的情况下,它必须在被GC(垃圾回收器)回收之前清除资源。

用第一个例子作为开头,如果自定义类型MyResourceManager有一个IntPtr字段怎么办?

class MyResourceManager

{

private IntPtr ptr;

public MyResourceManager()

{

ptr = Marshal.AllocHGlobal(1024);

}

}

在这种情况中,在构造对象后,在何时、何地释放ptr指向的内存才是有意义的?托管对象的生命周期被GC控制。可以用它作为回答这个问题的开始。实际上,您已经看过了块的建立:第3章介绍GC的内部,第5章特别介绍将要讨论的特性。

4. 终结(懒散清除)

终结函数使一个对象被GC回收时能够运行额外的代码。这是一个很好的“最后的机会”来回收任何和一个对象联系的资源(同样,考虑使用内存压力或者HandleCollector类型—— 讨论如下—— 来告诉GC对象在使用额外资源。这有助于加速终结,并减少整个系统的压力)。如果已经得到了一个对应日常资源的句柄,在终结的时候关闭它是至少需要做到的:

class MyResourceManager

{

private IntPtr ptr;

public MyResourceManager()

{

ptr = Marshal.AllocHGlobal(1024);

}

~MyResourceManager()

{

if (ptr != IntPtr.Zero)

{

Marshal.FreeHGlobal(ptr);

ptr = IntPtr.Zero;

}

}

}

对于多数资源—— 例如,文件句柄、数据库连接以及较少的COM对象—— 等待GC在将它们释放给系统前回收显然是不能接受的。使用这种资源的时间长于函数调用时间的对象完全应该提供一个确定的方式摆脱这些资源。

5. 可处理性(积极清除)

这正是IDisposable起作用的地方。如果一个类型实现了IDisposable接口,那么这指明了它至少控制了一个非GC资源。一位调用了Dispose方法的用户要求对象以确定的可预知的方式释放任何资源,而不是等待一个类型的终结函数在以后某个不确定的时间进行释放。这些类型同时提供一个终结函数以防有Dispose没有被调用的地方,这作为最后一个避免资源泄漏的屏障。

如果这样还不清楚,可以这样说,Dispose很像一个C++的析构函数:它提供一个确定的释放资源的方法。它可以和一个完全非确定的终结函数相比,并且确实是捕获资源泄漏的最后一次机会。实际上,现在C++/CLI 2.0将Dispose当作它的析构函数机制。如果用C++编写了一个析构函数,它将编译出一个Dispose方法,允许在使用一个来自C#的C++类型时可以利用using声明。除此以外,C++用户可以利用IDisposable类型的栈语义,并且在Dispose使用的范围的最后自动调用Dispose。

注意:

遗憾的是,C#语言的设计人员为C# 终结函数选择了一个与C++析构函数一样的语法(也就是~TypeName)。这造成了C#开发人员之间不断的混淆,这是真实的。幸运的是,现在这种区别已经变得清楚了。

在介绍IDisposable后,上面的类的实现变成:

sealed class MyResourceManager : IDisposable

{

private IntPtr ptr;

public MyResourceManager()

{

ptr = Marshal.AllocHGlobal(1024);

}

~MyResourceManager()

{

Dispose(false);

}

public void Dispose()

{

Dispose(true);

GC.SuppressFinalize(this);

}

private void Dispose(bool disposing)

{

if (ptr != IntPtr.Zero)

{

Marshal.FreeHGlobal(ptr);

ptr = IntPtr.Zero;

}

}

}

这个实现使您的类的用户能够将用法封装到一个using块中,并且一旦达到块的结束时就自动释放资源。注意,清除逻辑在Dispose(bool)方法中是由Dispose()和~MyResourceManager(终结)方法共享的。由于类被标记为密封的,这纯粹是一个实现的细节。但是对于子类的情况,这使资源的清除变得更加简单。

回忆先前讨论的句柄再循环。如果仔细检查上面的例子,将发现它易于出现这个问题。下面将看到如何用SafeHandle修正这个问题。

11.1.4  可靠地管理资源(SafeHandle)

在2.0版本中引入了一个新的类型,用以尝试消除和使用原始IntPtr有关的许多问题。它被称为SafeHandle,位于System.Runtime.InteropServices命名空间。SafeHandle虽然是一个简单的基本类型,却是一个强大的抽象类。在深入研究SafeHandle的成员的具体内容以及如何使用它们之前,先回顾一下SafeHandle的主要目标:

●       首先也是最重要的,SafeHandle使用一个引用计数算法来防止原始IntPtr用法会造成的句柄循环使用类型的攻击。它通过管理SafeHandle的生命周期达到这个目的,并且仅在引用计数值为0时才会关闭。P/Invoke编组程序的任务是,当一个句柄通过一个非托管边界时自动增加计数,并在返回时自动减少计数。在更复杂的情况下,可以用托管API来手动控制参数计数。例如,当传递一个SafeHandle通过线程边界时,一般需要负责增加引用计数。

●       通过关键终结确保在极端的关机条件下可靠地释放句柄(也就是说,SafeHandle由CriticalFinalizationObject派生而来)。这在复杂的主机情况中尤其重要—— 如在SQL Server中—— 它用来确保对所持的托管代码的资源使用有足够的保护,不会发生泄漏。关键终结是一个复杂的主题;本节以后会做出详细的介绍。

●       为类作者卸下资源管理和终结的负担并将之交与SafeHandle。如果封装了一个IntPtr,那么就要管理它的生命周期。即,需要自己编写一个Finalize和Dispose方法,并处理随之而来的声名狼藉的复杂设计和实现问题。经常需要调用GC.KeepAlive来确保finalizer不会在调用使用IntPtr的方法结束之前执行。现在有了SafeHandle,只要简单地编写一个调用内部SafeHandle的Dispose的Dispose方法即可,同时也消除了编写finalizer的痛苦。SafeHandle是一个被紧密封装的对象,在它生命周期里唯一的一个目标是:可靠地管理一个资源。

既然已经对为什么开发SafeHandle有了基本的了解,那么快速浏览一下如何使用它。在这之前有几点需要注意。第一,SafeHandle是一个抽象类。然而,多数人不需要考虑如何编写他们自己的实现,因为在Framework中已经有一些实现。第二,很少会看到实际的实现类;它们多数是内部的。第三,在开放情况下更难看到SafeHandle。多数类型,如FirstStream,将句柄作为私有状态。只有在通常使用IntPtr的情况下,才需要直接使用SafeHandle。

1. SafeHandle API概览

在研究自己的SafeHandle类的实现细节之前,先复习基类型的公共接口:

namespace System.Runtime.InteropServices

{

public abstract class SafeHandle : CriticalFinalizerObject, IDisposable

{

// Fields

protected IntPtr handle;

// De-/con-structor(s)

protected SafeHandle(IntPtr invalidHandleValue, bool ownsHandle);

protected ~SafeHandle();

// Properties

public bool IsClosed { get; }

public bool IsInvalid { get; }

// Methods

public void Close();

public void DangerousAddRef(ref bool success);

public IntPtr DangerousGetHandle();

public void DangerousRelease();

public void Dispose();

protected void Dispose(bool disposing);

protected bool ReleaseHandle();

protected void SetHandle(IntPtr handle);

public void SetHandleAsInvalid();

}

}

这里的多数成员都由在System.Runtime.InteropServices.ConstrainedExecution命名空间中建立的ReliabilityContractAttribute属性说明(在上面省略)。使用它的原因在于许多函数必须在受限执行区域(Constrained Execution Regions,简称CER)以非常严格的限制执行。多数方法确保它们将成功并且不会破坏状态。在这种限制下编写代码是异常困难的。在本章稍后会简单讨论CER。

所有API的总结如下。因为SafeHandle是一个抽象类,所以它没有公共构造函数。具体的实现可能会显示或者不显示。实际上,许多SafeHandle实现偏向于将构造函数隐藏于一些其他类型控制非常严密的工厂之后。不管在哪里发生,实现需要资源;调用基本SafeHandle构造函数将初始化内部引用计数,将其设置为1。

每个SafeHandle提供一个无效句柄的思想。当SafeHandle没有被正确初始化的时候,用它来进行检测;它可能由invalidHandleValue构造函数的参数设定并且可以用IsInvalid属性进行查询。如果handle字段和invalidHandleValue相等,则IsInvalid返回true。无效句柄数值各有不同。封装了一些最普通的值的Microsoft.Win32.SafeHandles命名空间中有这种类型集:SafeHandleZeroOrMinusOneIsInvalid判定IntPtr.Zero和值为-1的IntPtr是无效的,而SafeHandleZeroIsInvalid仅判定前者是代表一个无效的指针。您也许梦想有另一个神奇的值,对于任何您与之进行互操作的资源都是有意义的。

当一个客户线程正在使用SafeHandle实例时,它们将调用Close或者Dispose来指明。这实际上在内部减少引用计数并且将仅仅释放资源,这时满足两个条件:引用计数为0并且OwnsHandle在构造时为true。这使得与C#和VB的整合变得简单,因为可以简单地用一个using语句包装一个SafeHandle;C++/CLI则更加简单,如果用栈语义分配一个SafeHandle,它将在处于范围以外时自动释放。一旦实际的资源被释放,IsClosed将返回true。

最后,有一个DangerousXxx函数集。它们是危险的,因为当使用不当时,它们会导致遇到和使用IntPtr时相同的错误。DangerousAddRef将增加引用计数并返回一个布尔值以表明成功(true)或者失败(false)。DangerousRelease则起到相反的作用—— 它减少引用计数。如果增加一个引用失败—— 即,函数返回false—— 则必须完全不调用相应的释放函数。这会导致引用计数的失衡和一个危险的指针!

DangerousGetHandle只是这些函数中最不适用的一个,但是有时如果正在调用一个以一个IntPtr作为参数的托管方法,这仍然是必须的。它返回SafeHandle包装的原始IntPtr。要对这个指针非常小心;如果SafeHandle拥有句柄(多数情况是这样),它将马上关闭句柄,即使是它正在被使用的时候。一项通用的黄金法则是SafeHandle的生命周期至少和所使用的内部句柄的生命周期一样长。否则,将再次面临悬挂的指针。记住,当传递一个SafeHandle到非托管代码时,它处理将潜在的指针全部找出的进程,而同时确保引用计数是正确的。这不需要手动操作。

2. 简单的SafeHandle实现

SafeHandle的具体实现—— 例如,Microsoft.Win32.SafeHandles.SafeFileHandle—— 仅为句柄提供获取和释放的常规操作。所有其他的函数都由SafeHandle继承而来。这些新的函数的形式是一个构造函数(对于获取而言),它设置受保护的句柄字段以及一个ReleaseHandle(对于释放而言)的重载。作为一个简短的例子,考虑一个新的SafeHandle,它封装一个句柄到分配Marshal.AllocHGlobal的内存块中:

class HGlobalMemorySafeHandle : SafeHandleZeroOrMinusOneIsInvalid

{

public HGlobalMemorySafeHandle(int bytes) : base(true)

{

SetHandle(Marshal.AllocHGlobal(bytes));

}

[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]

protected override bool ReleaseHandle()

{

Marshal.FreeHGlobal(handle);

return true;

}

}

它用Marshal.AllocHGlobal方法在构造函数中分配了一块非托管内存,然后用重载的方法SafeHandle.ReleaseHandle方法释放那块内存。使用这种方法的客户端也许会采取如下做法:

using (SafeHandle sh = new HGlobalMemorySafeHandle(1024))

{

// Do something w/ 'sh'...

// For example, marshal it across a P/Invoke boundary.

}

客户端仅用自动在结束时调用Dispose的using声明包装SafeHandle。假设引用计数为0(例如,没有将它显示给其他在进程中使用它的线程),它将最终调用ReleaseHandle例程。

考虑如果有一个在内部使用SafeHandle的类。推荐将访问传递给句柄。例如,不应该要求您的用户获取SafeHandle以释放它。对于其他任何IDisposable类型的情况也一样,如果有一个实例并将其存储在一个字段中,类型将提供一个Dispose方法来释放底层的资源:

class MySafeHandleWrapper : IDisposable

{

private SafeHandle memoryHandle;

public MySafeHandleWrapper()

{

memoryHandle = new HGlobalMemorySafeHandle(1024);

}

public void Dispose()

{

SafeHandle handle = memoryHandle;

if (handle != null && !handle.IsClosed)

handle.Dispose();

}

}

注意,不需要考虑终结函数。如果有人忘记在一个MySafeHandleWrapper实例上调用Dispose(或者由于一个异步异常的失败,或者强制关机的阻碍),SafeHandle的终结函数将在以后的某个不确定的时候清空泄漏的资源。相比而言更好的是,SafeHandle有一个关键终结函数—— 就像将要看到的—— 它在某些情况下执行,而在此时一个普通的终结函数是不执行的。它的底线是帮助防止在如SQL Server的主机上泄漏资源。

11.1.5  通知GC资源消耗

GC完全忽略对象所持有的非托管资源。因此,它不会在计算整个系统压力时考虑这个因素。如果它只知道8字节对象已经分配了10GB的非托管内存,那么它可能会将它的优先级提高并尽快收集ASAP。

1. 内存压力

内存压力允许告诉GC这些内容。举个MyResourceManager类型的例子。对于GC,这个类的一个实例只占了4+x个字节,x是一个对象在运行时的一般性系统开销(实现细节且可变)。4表示IntPtr的大小(假设现在在一个32位的平台上执行;如果是在64位的平台上执行,那么指针就会是8个字节)。遗憾的是,它没有提到所分配的额外的1024字节以及由ptr字段指向的内容。

GC紧密注意程序中内存的状态。它使用启发的方式决定何时初始化一个回收以及回收期间在生成列表中所应该到达的程度。如果它知道对象持有这样巨大数量的非托管内存,且回收它可以释放一些内存(在这里的情况中是由于Finalize方法而发生这种情况),也许会以比回收仅仅几个字节还要快的速度来试图回收1K的对象。

多亏在.NET Framework的2.0版本中引入了一系列新的API来解决这个问题:GC.AddMemoryPressure和RemoveMemoryPressure。AddMemoryPressure指示GC它所不知道的额外内存正在被使用。RemoveMemoryPressure则告诉它该内存是何时被回收的。调用增加和移动的API应该总是平衡的。否则,在长时间运行的程序中,压力会显现并造成回收的发生过于频繁或者过于不频繁。

模式是在一旦内存被分配时则立刻增加压力,并在内存一旦被回收时减少压力。在示例类型MyResourceManager中,这分别表示构造函数和Dispose(bool)方法:

sealed class MyResourceManager : IDisposable

{

// Members not shown, e.g. fields, Dispose, Finalize, remain the same.

public MyResourceManager()

{

ptr = Marshal.AllocHGlobal(1024);

GC.AddMemoryPressure(1024);

}

private void Dispose(bool disposing)

{

if (ptr != IntPtr.Zero)

{

Marshal.FreeHGlobal(ptr);

ptr = IntPtr.Zero;

GC.RemoveMemoryPressure(1024);

}

}

}

这个策略对于不提供确定的清除机制的类尤其重要。注意内存压力系统的工作效率最高的时候是进行大量的加法和移动时。至少需要考虑的是页面层次的问题。一兆字节的数量会更好。

2. 句柄收集器

许多句柄关联稀少的或者受限的系统资源。这样的资源也许由一个信号量管理(System.Threading.Semaphore),该信号量跟踪可得的资源总数,以避免过多请求一个已经被用尽的资源。在这种模型中,资源获取请求客户端首先减少信号量(如果当前没有可获得的资源,就会阻塞);在释放资源以后,客户端必须立刻增加信号量,指明可用性(如果合适的话,唤醒其他请求线程)。这种策略经常很有用。但是如果句柄不是被确定地释放,则最终会处于一种情况,即其他的获取请求一直阻塞,直到一个句柄在终结时被释放。这是不确定的,并且会导致系统无响应。

HandleCollector类型提供了一个当特定资源相应的句柄数量超过指定限度时强制GC回收的方法。这种类型位于System.dll程序集中的System.Runtime.InteropServices命名空间中。构造一个实例,以一个回收将会发生的限度开始传递,然后以一个资源句柄类型在应用程序中共享那个实例。

在每个分配和释放中必须分别调用Add和Remove。如果增加一个新的引用会导致超过那个限度,那么一个GC就会被触发以清除任何在终结期间等待被回收的资源。应该始终在获取以前调用Add,并在释放前调用Remove。分配的句柄的总数可以由Count属性获得。

注意,如果超过了限度,那么试图执行Add操作是不会阻塞的。这只是给试图进行回收的GC一个提示。用信号量保护有限的资源仍然是必要的。

11.1.6  受限的执行区域

为了理解为什么受限的执行区域(Constrained Execution Regions,简称CER)是必要的,首先要理解像SQL Server的主机是如何孤立的,以及它们如何管理它们正在运行的代码。总而言之,这样的主机卸载整个AppDomain来关闭托管代码并同时保持进程(以及其他在其中的AppDomain)活动。很明显,在这种情况中,任何在AppDomain关闭前活动的句柄或者内存泄漏会在以后造成无限的增长。因此CLR在2.0版本中创建新的基础结构来更可靠地释放这些资源。

CER允许开发人员编写清除资源的可靠代码。CER有3种特性达到此目的:CER阻塞(调用RuntimeHelpers.PrepareConstrainedRegions初始化)、关键终结函数(由CriticalFinalizerObject派生的对象)以及对RuntimeHelpers.ExecuteCodeWith- GuaranteedCleanup调用。下面将看到相关的例子。CER和ReliabilityContractAttribute密切地互相作用以确保在CER中被调用的代码是合适的。

CER进行了积极准备,即它将分配堆和栈内存以避免在它的执行期间有任何内存溢出或者栈溢出的情况。为此,CLR必须预先知道来自CER内部的对所有代码的调用。然后预先对代码执行JIT操作并保证有足够的栈空间。当CER被允许显式分配内存时,多数的编写需要在假定这样做会触发内存溢出的条件下进行。ReliabilityContractAttribute—— 这将在下面研究—— 指示期望主机具有在CER中运行的一段代码。

详细的描述将需要一整章的篇幅。幸运的是,多数开发人员将永远不需要编写一个CER。

1. 在攻击性主机中的可靠清除

一些主机监控像内存分配(尤其是会导致磁盘分页的内存分配)、栈溢出和死锁这样的活动。这些情况都能够阻止一个在主机控制下的程序有进一步的发展。更糟糕的是,传统上,这些情况能够影响整个进程的代码。向前的发展对保证至少有一段代码能够达到这个目标来说是必要的,例如一个存储过程的执行直到完成。SQL Server将希望保持高可靠性、可扩展性,并为所有在服务器上运行的代码提供执行环境。换句话说,它必须一直最大化前进的步伐。因此,它做到两件事:(1)它孤立所有在一个AppDomain中相互间有逻辑关系的代码,(2)如果它检测到代码中有任何问题,会立刻停止错误代码的运行以进行响应。

主机可能通过CLR的宿主API改变它们的策略。但是, SQL对这种情况的响应是卸载AppDomain。这就是说一个异步ThreadAbortException异常将在一个目标线程中被抛出。有几个情况不会成功:如果那个线程上的代码是在一个CER、catch或者finally块、类构造函数或者一些非托管代码函数中,实际上主机将不能立刻引发异常。取而代之的是,它将在代码离开那个块(假设它没有嵌套在另一个块之中)之后引发。关于线程异常中止的详细内容,请参阅第10章。如果代码没有及时回应,SQL将进行强制关机。这个过程贯穿上述的受保护代码块。

此时,库代码负责确保没有进程范围(或者更糟的情况是,机器范围)的状态破坏。但是,举个例子,如果一个强制的异常中止没有考虑执行finally块的权利,那么如何保证资源被清除?除此以外,终结函数甚至不会在一个强制异常中止中运行,那么肯定将在一个强制异常中止中泄漏一些重要的资源,难道不是吗?其实不是这样:CLR实际上在一个强制异常中止中执行关键终结函数,这是因为关键终结函数依靠CER来避免一定级别的失败。

2. 关键终结函数

由System.Runtime.ConstrainedExecution.CriticalFinalizerObject生成的派生为类型提供了关键终结的好处。已经在本章看到过这样一个类的例子:SafeHandle。一个简单重载Finalize方法(在C#中为~TypeName())的实现确保将它注释为[ReliabilityContract(Consistency.WillNotCorruptState,Cer.Success)],并在那里进行它的清除。当然,它必须遵守这种合同暗示的受限执行诺言,下面将进一步对其进行定义。

例如:

using System.Runtime.ConstrainedExecution;

[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]

class MyCriticalType : CriticalFinalizerObject

{

private IntPtr handle;

MyCriticalType()

{

handle = OpenSomeHandle(...); // probably a p/invoke

}

~MyCriticalType()

{

CloseHandle(handle);

}

}

结果表明,在Framework中(除了SafeHandle)只有很少类提供这种保证,其中一个值得注意的类型是SecureString。这就是SecureString如何很好地确保避免在一个AppDomain关闭后,安全的字符串数据不存在于内存中。

3. 内联临界区

可以通过调用RuntimeHelpers.PrepareConstrainedRegions在进入一个try块之前引入一个CER块代码。这是一个JIT固有内容,它使CLR快速为代码做好准备。在这种情况下,只有finally块进行准备,而不是try块本身。但是所有都在进入try块之前做好了准备,所以可以确保任何清除代码是可以执行的:

RuntimeHelpers.PrepareConstrainedRegions();

try

{

// Unprotected, unconstrained code...

}

finally

{

// Reliably prepared code...

}

任何必须始终不被中断地执行的代码都进入finally块中。

4. ExecuteCodeWithGuaranteedCleanup临界区

RuntimeHelpers.ExecuteCodeWithGuaranteedCleanup方法可以用来初始化一个代码临界区。它接受两个委托参数,TryCode code和CleanupCode backoutCode,以及一个传递给这些委托的对象。委托签名的形式是:

public delegate void TryCode(object userData);

public delegate void CleanupCode(object userData, bool exceptionThrown);

这个函数的语义和上面手动准备的try块是一样的。换句话说,一个对ExecuteCode WithGuaranteedCleanup的调用在概念上和以下代码所示相同:

void ExecuteCodeWithGuaranteedCleanup(TryCode code,

CleanupCode backoutCode, object userData)

{

bool thrown = false;

RuntimeHelpers.PrepareConstrainedRegions();

try

{

code(userData);

}

catch

{

thrown = true;

throw;

}

finally

{

backoutCode(userData, thrown);

}

}

backoutCode委托在CER中执行,因此它应该遵守对临界区的约束。在try块中执行的code委托则不需要。

5. 可靠性合同

在受限区域中的代码和主机有一系列的隐式的协定。主机通过允许代码可靠的执行完成以返回所需要的内容。要实现一些诺言是困难的;实际上,多数人把编写正确的CER代码看作前沿科学。其中一个主要的难点是没有系统的方法来证明编写是否正确(除了大量主动性的测试)。例如,没有工具能够检测出对合同的违反。

用ReliabilityContractAttribute注释的方法声明了代码所作的保证。这个保证有两个部分:一致性和成功。它们向主机表明代码是否会失败,如果会,那么破坏状态的风险将是怎样的。这些由两个属性的参数表示,即Consistency和Cer类型:

●       Consistency是一个枚举类型,它提供4个值:MayCorruptInstance、MayCorrupt- AppDomain、MayCorruptProcess和WillNotCorruptState。前3个指明如果方法没有完成执行,它将处于破坏状态。破坏可能发生的顺序依次为,实例层次破坏(一个对象)、AppDomain破坏(线程间)或者进程层次破坏。每个破坏的严重性逐级增加并指明了主机如何主动地响应失败。WillNotCorruptState在实践中是很难实现的。它确保没有状态会被破坏。注意关键终结函数必须在这个保证下执行。

●       Cer有两个合法的值:MayFail告诉主机它预计会在执行期间有一个错误。如果一个错误发生,主机可以检查Consistency以决定可能发生的破坏的程度。Success指明执行一个给定的CER方法将永远不会失败。这和WillNotCorruptState一样,也在实践中难于实现。注意关键终结函数也在这个约束下执行。

所以,可靠的代码必须首先并且首先要声明它的可靠性和失败保证。但是此外,这样的代码也许只会至少以相同级别的保证调用其他方法。并且也许最坏的情况是,在像[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]这样的情况中,甚至不能分配任何内存!这意味着使newobj和box IL指令的任何使用变为无效。由于多数Framework API没有注释可靠性合同,这就是说多数API在CER中是没有限制的。这会使代码的编写变得非常困难。

CER在执行时就好像在使用合同运行:

[ReliabilityContract(Consistency.MayCorruptInstance, Cer.MayFail)]

此外,CER也许只用这些可靠性合同调用其他方法:

[ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]

[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]

[ReliabilityContract(Consistency.MayCorruptInstance, Cer.MayFail)]

[ReliabilityContract(Consistency.MayCorruptInstance, Cer.Success)]

注意,这些是相等的或者更严格的可靠性要求,和上面的解释相一致。

如果试图违反可靠性合同,那么会造成有损害的内存泄漏或者破坏:如果主机注意到,那么它一定会尝试阻止这样做。但是在某些情况中,它不会检测到这些情况。如果确保将只破坏实例状态,那么这时破坏进程范围的状态,主机将不会注意。它将视而不见,继续前进,这对应用程序或者数据的状态造成了潜在的破坏。

posted @ 2010-05-28 11:37  逆时针  阅读(5881)  评论(0编辑  收藏  举报