
一次性图案(一次性设计原则)
我猜几乎所有使用.NET的程序员现在都会说这种模式是小菜一碟。 这是平台上使用的最著名的模式。 但是,即使是最简单和众所周知的问题域,也将具有您从未看过的秘密区域。 因此,让我们从头开始为初学者描述所有事情,然后为所有其他人进行描述(以便你们每个人都可以记住基本知识)。 不要跳过这些段落-我在看着你!
如果我问什么是IDisposable,您肯定会说它是
public interface IDisposable { void Dispose(); }
接口的目的是什么? 我的意思是,如果我们有一个智能的垃圾收集器来代替我们清除内存,那么为什么我们根本需要清除内存,因此我们甚至不必考虑它。 但是,有一些小细节。
本章由作者和专业翻译员共同译自俄语。 您可以帮助我们将俄语或英语翻译成任何其他语言,主要是中文或德语。
另外,如果您想感谢我们,最好的方法是在github上给我们加星号或分支存储库
github / sidristij / dotnetbook
有一个误解,认为IDisposable
可用于释放非托管资源。 这只是部分正确,要理解它,您只需要记住非托管资源的示例即可。 File
类是非托管资源吗? 不行 也许DbContext
是非托管资源? 不,再说一次。 非托管资源是不属于.NET类型系统的资源。 平台未创建的内容,超出其范围的内容。 一个简单的示例是在操作系统中打开的文件句柄。 句柄是一个数字,唯一标识一个操作系统打开的文件-否,不是您自己。 也就是说,所有控制结构(例如,文件在文件系统中的位置,在发生碎片和其他服务信息的情况下的文件碎片,HDD的柱面,磁头或扇区的编号)都在OS内,但不是.NET平台。 传递给.NET平台的唯一非托管资源是IntPtr号。 该数字由FileSafeHandle包装,而FileSafeHandle由File类包装。 这意味着File类本身并不是非托管资源,而是使用IntPtr形式的附加层来包含非托管资源-已打开文件的句柄。 您如何读取该文件? 在WinAPI或Linux OS中使用一组方法。
多线程或多处理器程序中的同步原语是非托管资源的第二个示例。 这里属于通过P / Invoke传递的数据数组,还有互斥或信号量。
请注意,操作系统不只是将非托管资源的句柄传递给应用程序。 还将该句柄保存在进程打开的句柄表中。 因此,OS可以在应用程序终止后正确关闭资源。 这样可以确保在退出应用程序后仍将关闭资源。 但是,应用程序的运行时间可能有所不同,这可能会导致长时间的资源锁定。
好啦 现在我们讨论了非托管资源。 为什么在这些情况下需要使用IDisposable? 因为.NET Framework不知道其范围之外发生了什么。 如果使用OS API打开文件,.NET将一无所知。 如果您为自己的需要分配内存范围(例如使用VirtualAlloc),. NET也将一无所知。 如果不知道,它将不会释放VirtualAlloc调用占用的内存。 或者,它不会关闭直接通过OS API调用打开的文件。 这些可能会导致不同的意外后果。 如果分配过多的内存而不释放内存(例如,仅通过将指针设置为null),则可以获得OutOfMemory。 或者,如果通过OS打开文件共享上的文件而不关闭它,则将文件锁定在该文件共享上的时间很长。 文件共享示例特别好,因为即使关闭与服务器的连接,锁仍将保留在IIS端。 您没有释放锁定的权利,并且必须要求管理员使用特殊软件执行iisreset
或手动关闭资源。
远程服务器上的此问题可能成为要解决的复杂任务。
所有这些情况都需要通用且熟悉的协议来在类型系统和程序员之间进行交互 。 它应明确标识需要强制关闭的类型。 IDisposable接口正是用于此目的。 它以下列方式起作用:如果某个类型包含IDisposable接口的实现,则在使用该类型的实例完成工作之后,必须调用Dispose()。
因此,有两种标准的调用方法。 通常,您创建一个实体实例以在一种方法或实体实例的生存期内快速使用它。
第一种方法是using(...){ ... }
包装实例。 这意味着您指示在使用相关的块结束后销毁对象,即调用Dispose()。 第二种方法是在对象的生命周期结束时销毁该对象,并引用我们要释放的对象。 但是.NET除了终结处理方法以外,没有什么可以暗示对象自动销毁的,对吗? 但是,终结处理根本不合适,因为我们不知道何时调用终结处理。 同时,我们需要在某个特定时间释放对象,例如刚完成打开文件的工作之后。 这就是为什么我们还需要实现IDisposable并调用Dispose释放我们拥有的所有资源的原因。 因此,我们遵循该协议 ,这非常重要。 因为如果有人跟随它,那么所有参与者都应该这样做,以避免出现问题。
实现IDisposable的不同方法
让我们看一下IDisposable的实现,从简单到复杂。 第一种也是最简单的方法是按原样使用IDisposable:
public class ResourceHolder : IDisposable { DisposableResource _anotherResource = new DisposableResource(); public void Dispose() { _anotherResource.Dispose(); } }
在这里,我们创建由Dispose()进一步释放的资源的实例。 唯一使该实现不一致的是,在实例被Dispose()
销毁后,您仍然可以使用该实例:
public class ResourceHolder : IDisposable { private DisposableResource _anotherResource = new DisposableResource(); private bool _disposed; public void Dispose() { if(_disposed) return; _anotherResource.Dispose(); _disposed = true; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CheckDisposed() { if(_disposed) { throw new ObjectDisposedException(); } } }
在类的所有公共方法中,必须将CheckDisposed()作为第一个表达式调用。 获得的ResourceHolder
类结构看起来很不错,可以销毁非托管资源DisposableResource
。 但是,此结构不适用于包装的非托管资源。 让我们看一下具有非托管资源的示例。
public class FileWrapper : IDisposable { IntPtr _handle; public FileWrapper(string name) { _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero); } public void Dispose() { CloseHandle(_handle); } [DllImport("kernel32.dll", EntryPoint = "CreateFile", SetLastError = true)] private static extern IntPtr CreateFile(String lpFileName, UInt32 dwDesiredAccess, UInt32 dwShareMode, IntPtr lpSecurityAttributes, UInt32 dwCreationDisposition, UInt32 dwFlagsAndAttributes, IntPtr hTemplateFile); [DllImport("kernel32.dll", SetLastError=true)] private static extern bool CloseHandle(IntPtr hObject); }
最后两个示例的行为有何不同? 第一个描述了两个托管资源的交互。 这意味着,如果程序正常运行,则资源仍将被释放。 由于DisposableResource
是受管理的,.NET CLR知道它,并且如果其行为不正确,则会从中释放内存。 请注意,我有意识地不假设DisposableResource
类型封装了什么。 可以有任何种类的逻辑和结构。 它可以包含托管资源和非托管资源。 这根本不应该与我们有关 。 没有人要求我们每次都对第三方库进行反编译,以查看它们是否使用托管资源或非托管资源。 而且,如果我们的类型使用非托管资源,那么我们就不会不知道这一点。 我们在FileWrapper
类中执行此FileWrapper
。 那么,在这种情况下会发生什么呢? 如果我们使用非托管资源,则有两种情况。 第一个是当一切正常并调用Dispose时。 第二个是出现问题而处置失败时。
让我们直接说一下为什么可能会出错:
- 如果我们使用
using(obj) { ... }
,则内部代码块中可能会出现异常。 这个异常被finally
块捕获,我们看不到(这是C#的语法糖)。 该块隐式调用Dispose。 但是,在某些情况下不会发生这种情况。 例如,既不catch
也不finally
捕获StackOverflowException
。 您应该永远记住这一点。 因为如果某个线程变得递归并且某个时刻发生了StackOverflowException
,则.NET将忘记它使用但未释放的资源。 它不知道如何释放非托管资源。 它们将保留在内存中,直到OS释放它们为止,即,当您退出程序时,甚至在应用程序终止后的某个时间。 - 如果我们从另一个Dispose()调用Dispose()。 同样,我们可能碰巧无法做到这一点。 心不在app的应用程序开发人员不是这种情况,他们忘记了调用Dispose()。 这是例外的问题。 但是,这些不仅是使应用程序线程崩溃的异常。 在这里,我们讨论所有将阻止算法调用将调用Dispose()的外部Dispose()的异常。
所有这些情况将创建暂停的非托管资源。 那是因为垃圾收集器不知道应该收集它们。 在下一次检查时,它所能做的就是发现丢失了对我们FileWrapper
类型的对象图的最后引用。 在这种情况下,将为带有引用的对象重新分配内存。 我们如何预防呢?
我们必须实现对象的终结器。 “终结者”是故意这样命名的。 它似乎不是析构函数,因为在C#中调用终结器和在C ++中调用析构函数的方式类似。 区别在于,无论析构函数(以及Dispose()
)如何,都将调用finalizer。 启动垃圾回收时将调用终结器(现在已经足够知道这一点,但是事情要复杂一些)。 如果出现问题,它用于保证释放资源。 我们必须实现终结器以释放非托管资源。 同样,由于启动GC时将调用终结器,因此一般情况下我们不知道这种情况。
让我们扩展代码:
public class FileWrapper : IDisposable { IntPtr _handle; public FileWrapper(string name) { _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero); } public void Dispose() { InternalDispose(); GC.SuppressFinalize(this); } private void InternalDispose() { CloseHandle(_handle); } ~FileWrapper() { InternalDispose(); } /// other methods }
我们利用有关完成过程的知识增强了该示例,并在未调用Dispose()的情况下确保应用程序不会丢失资源信息。 我们还调用了GC。SuppressFinalize,以在成功调用Dispose()时禁用类型实例的终结处理。 不需要两次释放相同的资源,对吗? 因此,我们还会在一段时间后放开一个可能与最终化并行运行的代码的随机区域,从而减少最终化队列。 现在,让我们进一步增强示例。
public class FileWrapper : IDisposable { IntPtr _handle; bool _disposed; public FileWrapper(string name) { _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero); } public void Dispose() { if(_disposed) return; _disposed = true; InternalDispose(); GC.SuppressFinalize(this); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CheckDisposed() { if(_disposed) { throw new ObjectDisposedException(); } } private void InternalDispose() { CloseHandle(_handle); } ~FileWrapper() { InternalDispose(); } /// other methods }
现在,我们的示例封装了非托管资源,看起来很完整。 不幸的是,第二个Dispose()
实际上是平台的标准,我们允许对其进行调用。 请注意,人们通常允许第二次调用Dispose()
以避免调用代码出现问题,这是错误的。 但是,您图书馆的用户在查看MS文档时可能不会这样,因此将允许多次调用Dispose()。 无论如何,调用其他公共方法将破坏对象的完整性。 如果我们销毁了对象,我们将无法再使用它。 这意味着我们必须在每个公共方法的开始处调用CheckDisposed
。
但是,此代码包含一个严重的问题,导致其无法按预期工作。 如果我们记得垃圾回收的工作原理,我们将注意到其中一项功能。 在收集垃圾时,GC首先完成所有直接从Object继承的内容。 接下来,它处理实现CriticalFinalizerObject的对象。 由于我们设计的两个类都继承Object,因此这成为一个问题。 我们不知道它们将以什么顺序到达“最后一英里”。 但是,更高级别的对象可以使用其终结器来终结具有非托管资源的对象。 虽然,这听起来不是一个好主意。 此处的完成顺序将非常有帮助。 要进行设置,必须从CriticalFinalizerObject
继承具有封装的非托管资源的低级类型。
第二个原因更为深刻。 想象一下,您敢于编写一个不需要太多内存的应用程序。 它大量分配内存,而无需现金和其他微妙之处。 有一天,该应用程序将因OutOfMemoryException崩溃。 发生这种情况时,代码将专门运行。 它不能分配任何东西,因为即使导致第一个异常被捕获,也会导致重复的异常。 这并不意味着我们不应该创建对象的新实例。 即使是简单的方法调用也可能引发此异常,例如终结处理。 我提醒您,初次调用方法时会对其进行编译。 这是通常的行为。 我们如何预防这个问题? 很容易。 如果您的对象是从CriticalFinalizerObject继承的,则此类型的所有方法都将在将其加载到内存中后立即编译。 此外,如果使用[PrePrepareMethod]属性标记方法,则它们也将被预编译,并且在资源不足的情况下可以安全地调用。
为什么这么重要? 为什么要对那些过世的人投入过多的精力呢? 因为非托管资源可以在系统中长期挂起。 即使在重新启动计算机之后。 如果用户从应用程序中的文件共享中打开文件,则前者将被远程主机锁定,并在超时或通过关闭文件释放资源时释放。 如果您的应用程序在打开文件时崩溃,那么即使重新启动它也不会被释放。 您将需要等待很长时间,直到远程主机释放它。 另外,您不应在终结器中允许例外。 这将导致CLR和应用程序加速崩溃,因为您无法将finalizer的调用包装在try ... catch中 。 我的意思是,当您尝试释放资源时,必须确保可以释放该资源。 最后但并非不重要的事实:如果CLR异常卸载域,则也将调用从CriticalFinalizerObject派生的类型的终结器,这与直接从Object继承的终结器不同。
该章程由专业翻译人员从俄语译为作者的语言 。 您可以帮助我们使用俄语和英语版本的文本作为源来创建该文本到其他任何语言(包括中文或德语)的翻译版本。
另外,如果您想说“谢谢”,那么您可以选择的最好方法是在github或fork库上给我们加星号
https://github.com/sidristij/dotnetbook