
多线程
现在让我们谈谈薄冰。 在前面有关IDisposable的章节中,我们触及了一个非常重要的概念,该概念不仅是Disposable类型的设计原理的基础,而且还是一般类型的基础。 这是对象的完整性概念。 这意味着在任何给定的时间,对象都处于严格确定的状态,并且对该对象的任何操作都会将其状态转换为设计此对象的类型时预先确定的选项之一。 换句话说,对对象的任何操作都不应将其变成未定义状态。 这导致上述示例中设计的类型存在问题。 它们不是线程安全的。 当销毁对象时,可能会调用这些类型的公共方法。 让我们解决这个问题并决定是否应该彻底解决它。
本章由作者和专业翻译员共同译自俄语。 您可以帮助我们将俄语或英语翻译成任何其他语言,主要是中文或德语。
另外,如果您想感谢我们,最好的方法是在github上给我们加星号或分支存储库
github / sidristij / dotnetbook
public class FileWrapper : IDisposable { IntPtr _handle; bool _disposed; object _disposingSync = new object(); public FileWrapper(string name) { _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero); } public void Seek(int position) { lock(_disposingSync) { CheckDisposed(); // Seek API call } } public void Dispose() { lock(_disposingSync) { if(_disposed) return; _disposed = true; } InternalDispose(); GC.SuppressFinalize(this); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CheckDisposed() { lock(_disposingSync) { if(_disposed) { throw new ObjectDisposedException(); } } } private void InternalDispose() { CloseHandle(_handle); } ~FileWrapper() { InternalDispose(); } /// other methods }
Dispose()中的_disposed
验证代码应初始化为关键部分。 实际上,应将公共方法的整个代码初始化为关键部分。 这将解决并发访问实例类型的公共方法及其销毁方法的问题。 但是,它带来了其他问题,成为了定时炸弹:
- 大量使用类型实例方法以及对象的创建和销毁将大大降低性能。 这是因为进行锁定会浪费时间。 这是分配SyncBlockIndex表,检查当前线程和许多其他事情的必要时间(我们将在有关多线程的章节中进行介绍)。 这意味着我们将不得不在生命的“最后一英里”内牺牲其生命周期内的性能。
- 同步对象的其他内存流量。
- GC应该采取的其他步骤来遍历对象图。
现在,让我们命名第二个,我认为是最重要的。 我们允许销毁一个对象,同时期望再次使用它。 在这种情况下,我们希望什么? 会失败吗? 因为如果首先运行Dispose,那么接下来使用对象方法肯定会导致ObjectDisposedException
。 因此,您应该将Dispose()调用与其他类型的公共方法之间的同步委托给服务端,即FileWrapper
给创建FileWrapper
类实例的FileWrapper
。 这是因为只有创建方才知道它将对一个类的实例执行什么操作以及何时销毁它。 另一方面,Dispose调用仅应产生严重错误,例如OutOfMemoryException
,而不是IOException。 这是因为实现IDisposable的类的体系结构要求。 这意味着,如果一次从多个线程中调用Dispose,则可能同时从两个线程中破坏一个实体(我们跳过对if(_disposed) return;
的检查)。 这取决于情况:如果可以多次释放资源,则无需进行其他检查。 否则,必须进行保护:
// I don't show the whole pattern on purpose as the example will be too long // and will not show the essence class Disposable : IDisposable { private volatile int _disposed; public void Dispose() { if(Interlocked.CompareExchange(ref _disposed, 1, 0) == 0) { // dispose } } }
一次性设计原理的两个层次
在.NET书籍和Internet中可以实现的最受欢迎的实现IDisposable
模式是什么? 面试期间您期望您找到什么样的新工作模式? 最可能的是:
public class Disposable : IDisposable { bool _disposed; public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if(disposing) { // here we release managed resources } // here we release unmanaged resources } protected void CheckDisposed() { if(_disposed) { throw new ObjectDisposedException(); } } ~Disposable() { Dispose(false); } }
这个例子有什么问题,为什么我们以前没有这样写? 实际上,这是适合所有情况的良好模式。 但是,在我看来,无处不在使用它不是一种好的样式,因为在实践中我们几乎不处理非托管资源,这使得一半的模式毫无用处。 此外,由于它同时管理托管和非托管资源,因此违反了责任划分的原则。 我认为这是错误的。 让我们看一个稍微不同的方法。 一次性设计原则 。 简而言之,它的工作方式如下:
处置分为两个级别的类:
- 0级类型直接封装非托管资源
- 它们是抽象的或打包的。
- 所有方法都应标记:
-PrePrepareMethod,以便在加载类型时可以编译方法
- SecuritySafeCritical可以防止代码调用,在限制下工作
- ReliabilityContract(Consistency.WillNotCorruptState,Cer.Success / MayFail)]将CER放入方法及其所有子调用中
-他们可以引用0级类型,但应增加引用对象的计数器,以确保输入“最后一英里”的正确顺序
- 级别1类型仅封装托管资源
- 它们仅从1级类型继承,或直接实现IDisposable
- 他们不能继承0级类型或CriticalFinalizerObject
- 它们可以封装1级和0级托管类型
- 它们实现IDisposable,通过销毁从Level 0类型开始到Level 1的封装对象进行处理。
- 他们不实现终结器,因为它们不处理非托管资源
- 它们应该包含一个受保护的属性,该属性可以访问0级类型。
这就是为什么我从一开始就将划分使用两种类型的原因:一种包含托管资源,另一种包含非托管资源。 它们的功能应有所不同。
其他使用Dispose的方式
创建IDisposable背后的想法是释放非托管资源。 但是,与其他许多模式一样,它对其他任务也非常有用,例如,释放对托管资源的引用。 尽管释放托管资源听起来不是很有帮助。 我的意思是说它们被故意地称为“托管”,因此我们会对C / C ++开发人员露出微笑,对吗? 但是,事实并非如此。 在某些情况下,我们可能会丢失对某个对象的引用,但同时又认为一切正常:GC将收集包括我们的对象在内的垃圾。 但是,事实证明,内存在增长。 我们进入内存分析程序,发现还有其他东西可以容纳这个对象。 事实是,在.NET平台和外部类的体系结构中,可能存在隐式捕获对实体的引用的逻辑。 由于捕获是隐式的,因此程序员可能会错过释放它的必要性,然后导致内存泄漏。
代表,活动
让我们看一下这个综合示例:
class Secondary { Action _action; void SaveForUseInFuture(Action action) { _action = action; } public void CallAction() { _action(); } } class Primary { Secondary _foo = new Secondary(); public void PlanSayHello() { _foo.SaveForUseInFuture(Strategy); } public void SayHello() { _foo.CallAction(); } void Strategy() { Console.WriteLine("Hello!"); } }
此代码显示哪个问题? 辅助类将Action
类型委托存储在SaveForUseInFuture
方法接受的_action
字段中。 接下来, Primary
类中的PlanSayHello
方法将指向Strategy
方法的指针传递给Secondary
类。 很好奇,但是,在此示例中,如果您在某个地方传递了静态方法或实例方法,则传递的SaveForUseInFuture
不会被更改,但是会隐式地引用或根本不引用Primary
类实例。 从外观上看,您已指示要调用的方法。 但是实际上,不仅使用方法指针构建委托,而且使用指向类实例的指针构建委托。 调用方应该了解必须为类的哪个实例调用Strategy
方法! 这是Secondary
类的实例已隐式接受并保存了指向Primary
类的实例的指针,尽管未明确指出。 对我们来说,这仅意味着如果我们在其他地方传递_foo
指针并丢失对Primary
的引用,则GC 将不会收集 Primary
对象,因为Secondary
将保留它。 我们如何避免这种情况? 我们需要一种确定的方法来发布对我们的引用。 完全适合此目的的机制是IDisposable
// This is a simplified implementation class Secondary : IDisposable { Action _action; public event Action<Secondary> OnDisposed; public void SaveForUseInFuture(Action action) { _action = action; } public void CallAction() { _action?.Invoke(); } void Dispose() { _action = null; OnDisposed?.Invoke(this); } }
现在该示例看起来可以接受。 如果将类的实例传递给第三方,并且在此过程中对_action
委托的引用将丢失,我们将其设置为零,并且将通知第三方有关实例销毁的信息并删除对该实例的引用。
在委托上运行的代码的第二个危险是event
的起作用原理。 让我们看看它们的结果:
// a private field of a handler private Action<Secondary> _event; // add/remove methods are marked as [MethodImpl(MethodImplOptions.Synchronized)] // that is similar to lock(this) public event Action<Secondary> OnDisposed { add { lock(this) { _event += value; } } remove { lock(this) { _event -= value; } } }
C#消息传递隐藏了事件的内部,并保留了所有通过event
进行更新的对象。 如果出现问题,对签名对象的引用将保留在OnDisposed
,并将保留该对象。 这是一种奇怪的情况,因为在体系结构方面,我们得到了“事件源”的概念,该概念不应在逻辑上持有任何东西。 但是实际上,订阅更新的对象是隐式持有的。 此外,尽管实体属于我们,但我们无法在此委托数组中进行任何更改。 我们唯一可以做的就是通过将null分配给事件源来删除此列表。
第二种方法是显式实现add
/ remove
方法,因此我们可以控制一组委托。
另一个隐式情况可能会出现在这里。 似乎如果将null分配给事件源,则对事件的以下预订将导致NullReferenceException
。 我认为这会更合逻辑。
但是,事实并非如此。 如果在清除事件源之后外部代码订阅了事件,则FCL将创建Action类的新实例并将其存储在OnDisposed
。 C#中的这种隐式性可能会误导程序员:处理空字段应该产生一种警惕而不是平静。 在这里,我们还演示了当程序员的粗心大意可能导致内存泄漏时的一种方法。
Lambdas封口
使用lambdas这样的语法糖特别危险。
我想整体谈谈语法糖。 我认为您应该相当谨慎地使用它,并且只有在您完全知道结果的情况下。 使用lambda表达式的示例包括闭包,表达式中的闭包以及您可能会对自己施加的许多其他麻烦。
当然,您可能会说您知道lambda表达式会创建一个闭包,并可能导致资源泄漏的风险。 但这是如此整洁,令人愉快,以至于很难避免使用lambda而不是分配整个方法,这将在与使用方法不同的地方进行描述。 实际上,尽管不是每个人都可以抗拒,但您不应接受这种挑衅。 让我们看一个例子:
button.Clicked += () => service.SendMessageAsync(MessageType.Deploy);
同意,这行看起来很安全。 但这隐藏了一个大问题:现在button
变量隐式引用了service
并保留了它。 即使我们决定不再需要service
,在此变量处于活动状态时, button
仍将保留引用。 解决此问题的方法之一是使用一种模式来从任何Action
( System.Reactive.Disposables
)创建IDisposable
:
// Here we create a delegate from a lambda Action action = () => service.SendMessageAsync(MessageType.Deploy); // Here we subscribe button.Clicked += action; // We unsubscribe var subscription = Disposable.Create(() => button.Clicked -= action); // where it is necessary subscription.Dispose();
承认,这看起来有点冗长,我们失去了使用lambda表达式的全部目的。 使用通用私有方法隐式捕获变量更加安全和简单。
线程中断保护
当您为第三方开发人员创建库时,您无法预测其在第三方应用程序中的行为。 有时,您只能猜测程序员对您的库所做的操作,从而导致特定的结果。 一个示例是在多线程环境中工作,此时资源清理的一致性可能成为一个关键问题。 请注意,当我们编写Dispose()
方法时,我们可以保证没有异常。 但是,我们无法确保在运行Dispose()
方法时不会发生ThreadAbortException
从而禁用我们的执行线程。 这里我们应该记住,当发生ThreadAbortException
时,无论如何都会执行所有catch / finally块(在catch / finally块的末尾,ThreadAbort会继续发生)。 因此,要确保使用Thread.Abort执行某些代码,您需要在try { ... } finally { ... }
包装一个关键部分,请参见以下示例:
void Dispose() { if(_disposed) return; _someInstance.Unsubscribe(this); _disposed = true; }
可以随时使用Thread.Abort
中止此操作。 尽管您将来仍可以使用它,但它会部分破坏对象。 同时,以下代码:
void Dispose() { if(_disposed) return; // ThreadAbortException protection try {} finally { _someInstance.Unsubscribe(this); _disposed = true; } }
可以防止此类异常中止,并且即使在调用Unsubscribe
方法与执行其指令之间出现Thread.Abort
,它也可以平稳,可靠地运行。
结果
优势优势
好吧,我们从这种最简单的模式中学到了很多。 让我们确定它的优点:
- 该模式的主要优点是可以确定地释放资源,即在需要时释放资源。
- 第二个优点是引入了一种行之有效的方法来检查特定实例是否需要在使用后销毁其实例。
- 如果正确实现该模式,则设计的类型将在第三方组件使用以及进程崩溃(例如由于内存不足)而卸载和销毁资源方面安全地起作用。 这是最后一个优势。
缺点
我认为,这种模式弊大于利。
- 一方面,任何实现此模式的类型都会指示其他部分,如果他们使用它,则会进行某种公开报价。 这是如此隐含,以至于在公开发售的情况下,某个类型的用户并不总是知道该类型具有此接口。 因此,您必须遵循IDE提示(键入句点,Dis ...,并检查类的筛选成员列表中是否存在方法)。 如果看到Dispose模式,则应在代码中实现它。 有时并不会立即发生,在这种情况下,您应该通过添加功能的类型系统来实现模式。 一个很好的例子是
IEnumerator<T>
需要IDisposable
。 - 通常,在设计接口时,当其中一个接口必须继承IDisposable时,需要将IDisposable插入类型接口的系统中。 我认为,这会损害我们设计的接口。 我的意思是在设计接口时,首先要创建一个交互协议。 这是您可以在界面后面隐藏的内容下执行的一组操作。
Dispose()
是销毁类实例的方法。 这与交互协议的本质相矛盾。 实际上,这些是渗透到接口中的实现细节。 - 尽管已确定,但Dispose()并不意味着直接销毁对象。 该对象在销毁后仍将存在,但处于另一种状态。 要使其正确,CheckDisposed()必须是每个公共方法的第一个命令。 这似乎是有人给我们说的临时解决方案:
- 通过显式实现获得实现
IDisposable
的类型的机会也很小。 或者,您可以得到实现isposable的ID的类型,而没有机会确定谁必须销毁它:您或提供ID的一方。 这导致多次调用Dispose()的反模式,该模式允许销毁已销毁的对象。 - 完整的实现很困难,对于托管资源和非托管资源而言,它是不同的。 在这里,通过GC促进开发人员工作的尝试看起来很尴尬。 您可以重写
virtual void Dispose()
方法,并引入一些实现整个模式的DisposableObject类型,但不能解决与该模式有关的其他问题。 - 通常,Dispose()方法在文件末尾实现,而'.ctor'在开头声明。 如果您修改类或引入新资源,则很容易忘记为它们添加处置。
- 最后,当将模式用于对象图时,要确定对象的完全或部分实现方式,很难确定多线程环境中的破坏顺序。 我的意思是Dispose()可以在图形的不同末端开始的情况。 在这里最好使用其他模式,例如生命周期模式。
- 平台开发人员希望结合实际情况自动执行内存控制:应用程序经常与非托管代码进行交互+您需要控制对对象的引用的释放,以便Garbage Collector可以收集它们。 这在理解以下问题时增加了极大的困惑:“我们应如何正确实施模式”? “是否有可靠的模式”? 也许调用
delete obj; delete[] arr;
delete obj; delete[] arr;
更简单?
域卸载并退出应用程序
如果您掌握了这一部分,您将对以后的工作面试的成功充满信心。 但是,我们并未讨论与这个简单的(似乎是)模式有关的所有问题。 最后一个问题是,在简单垃圾收集的情况下,以及在域卸载期间和退出应用程序时收集垃圾时,应用程序的行为是否有所不同。 这个问题只是触及Dispose()
...。但是Dispose()
和finalization并驾齐驱,我们很少遇到具有finalize但没有Dispose()
方法的类的实现。 因此,让我们在单独的部分中描述终结处理。 在这里,我们只添加一些重要的细节。
在卸载应用程序域期间,您将卸载加载到应用程序域中的两个程序集以及作为要卸载的域的一部分而创建的所有对象。 实际上,这意味着清理(通过GC收集)这些对象并为它们调用终结器。 如果终结器的逻辑等待其他对象的终结以正确的顺序进行终结,则您可能要注意Environment.HasShutdownStarted
属性(指示从内存中卸载应用程序)和AppDomain.CurrentDomain.IsFinalizingForUnload()
方法,以指示此操作已从内存中卸载。域已卸载,这是完成的原因。 如果发生这些事件,则资源确定的顺序通常变得不重要。 我们不能延迟域或应用程序的卸载,因为我们应该尽快完成所有工作。
这是作为类LoaderAllocatorScout的一部分解决此任务的方式
// Assemblies and LoaderAllocators will be cleaned up during AppDomain shutdown in // an unmanaged code // So it is ok to skip reregistration and cleanup for finalization during appdomain shutdown. // We also avoid early finalization of LoaderAllocatorScout due to AD unload when the object was inside DelayedFinalizationList. if (!Environment.HasShutdownStarted && !AppDomain.CurrentDomain.IsFinalizingForUnload()) { // Destroy returns false if the managed LoaderAllocator is still alive. if (!Destroy(m_nativeLoaderAllocator)) { // Somebody might have been holding a reference on us via weak handle. // We will keep trying. It will be hopefully released eventually. GC.ReRegisterForFinalize(this); } }
典型的实施故障
正如我向您展示的那样,没有实现IDisposable的通用模式。 而且,对自动内存控制的某些依赖会误导人们,并且在实施模式时他们会做出令人困惑的决策。 整个.NET Framework实施过程中充满错误。 为了证明我的观点,让我们确切地使用.NET Framework示例查看这些错误。 所有实现均可通过以下方式获得: IDisposable用法
FileEntry类 cmsinterop.cs
匆忙编写此代码只是为了解决问题。 显然,作者想做点什么,但改变了主意,并提出了一个有缺陷的解决方案
internal class FileEntry : IDisposable { // Other fields // ... [MarshalAs(UnmanagedType.SysInt)] public IntPtr HashValue; // ... ~FileEntry() { Dispose(false); } // The implementation is hidden and complicates calling the *right* version of a method. void IDisposable.Dispose() { this.Dispose(true); } // Choosing a public method is a serious mistake that allows for incorrect destruction of // an instance of a class. Moreover, you CANNOT call this method from the outside public void Dispose(bool fDisposing) { if (HashValue != IntPtr.Zero) { Marshal.FreeCoTaskMem(HashValue); HashValue = IntPtr.Zero; } if (fDisposing) { if( MuiMapping != null) { MuiMapping.Dispose(true); MuiMapping = null; } System.GC.SuppressFinalize(this); } } }
SemaphoreSlim类 系统/线程/ SemaphoreSlim.cs
此错误是有关IDisposable的.NET Framework错误的顶部,即:没有终结器的类的SuppressFinalize。 这是很常见的。
public void Dispose() { Dispose(true); // As the class doesn't have a finalizer, there is no need in GC.SuppressFinalize GC.SuppressFinalize(this); } // The implementation of this pattern assumes the finalizer exists. But it doesn't. // It was possible to do with just public virtual void Dispose() protected virtual void Dispose(bool disposing) { if (disposing) { if (m_waitHandle != null) { m_waitHandle.Close(); m_waitHandle = null; } m_lockObj = null; m_asyncHead = null; m_asyncTail = null; } }
调用Close +处理 一些NativeWatcher项目代码
有时人们将关闭和处置同时称为。 这是错误的,尽管它不会产生错误,因为第二个Dispose不会生成异常。
实际上,关闭是另一种使人们更清楚的方式。 但是,这使得一切都不清楚。
public void Dispose() { if (MainForm != null) { MainForm.Close(); MainForm.Dispose(); } MainForm = null; }
一般结果
- IDposable是平台的标准,其实现的质量会影响整个应用程序的质量。 此外,在某些情况下,它会影响您的应用程序的安全性,并可能受到不受管理的资源的攻击。
- IDisposable的实现必须具有最大的生产力。 对于完成部分,这与其余代码并行工作(加载垃圾收集器)特别重要。
- 实现IDisposable时,不应与类的公共方法同时使用Dispose()。 销毁不能伴随使用。 在设计将使用IDisposable对象的类型时应考虑这一点。
- 但是,应该有防止同时从两个线程调用'Dispose()'的保护措施。 这是由于Dispose()不应产生错误的陈述导致的。
- 包含非托管资源的类型应与其他类型分开。 我的意思是,如果包装了非托管资源,则应该为其分配一个单独的类型。 此类型应包含终结处理,并且应继承自
SafeHandle / CriticalHandle / CriticalFinalizerObject
。 责任的分离将改善类型系统的支持,并简化通过Dispose()销毁类型实例的实现:使用此实现的类型将不需要实现终结器。 - 通常,此模式在使用和代码维护中都不方便。 当我们通过
Lifetime
模式破坏对象的状态时,可能应该使用控制反转方法。 但是,我们将在下一节中讨论它。
本章由作者和专业翻译员共同译自俄语。 您可以帮助我们将俄语或英语翻译成任何其他语言,主要是中文或德语。
另外,如果您想感谢我们,最好的方法是在github上给我们加星号或分支存储库
github / sidristij / dotnetbook