软件必须具有复原性,意思是说在出现内部和外部中断情况时,它仍然可以继续正常运行。它必须是可恢复的,以便它知道如何将自己恢复到先前已知的一致状态。软件必须可预测,这样它会提供及时的预期服务。它必须不可中断,意思是更改和升级都不会影响它的服务。最后,软件必须是生产就绪的,意思是它包含最少的 bug,并且只需要进行数量有限的更新。如果满足了这些条件,那么软件就真正称得上可靠了。
可靠代码的这些关键属性取决于不同的因素 — 有些取决于软件的整体体系结构,有些取决于将运行软件的操作系统,还有一些则取决于用来开发应用程序的工具和构建应用程序所基于的框架。复原能力是一种依赖于每一层的属性,应用程序的复原能力取决于其最薄弱的一环。
现在,请设想一下基于 Microsoft .NET Framework 的应用程序。这些应用程序委托运行时进行某些操作,这些操作在本机环境中不存在(例如 IL 代码的实时编译),或者已处于开发人员的直接控制之下(例如内存管理)。就可靠性而言,平台自身可以引入自己的故障点,这些故障点会影响在其上运行的应用程序的可靠性。了解这些故障可能在哪里发生以及可以使用什么样的技术来创建更可靠的基于 .NET 的应用程序非常重要。
了解运行时故障
某些异常事件在任何时候、任何代码段中都有可能发生。这些事件我们统称为异步异常,包括资源耗尽(内存不足和堆栈溢出)、线程终止和访问冲突。(在执行托管代码时,访问冲突会在运行时中发生。)
最后这个情形不是很有意义 — 如果确实发生了这样的事件,就意味着公共语言运行时 (CLR) 实现中发现了严重的 bug,应予以修复。但是对前两种情形,有必要进行进一步的分析。
理论上,我们会认为资源耗尽会得到运行时的妥善管理,并且它们绝不会影响应用程序代码继续运行的能力。可这只是理论,实际情况要复杂得多。
为了说明这个问题,我们首先来看一下某些常见的服务器应用程序如何处理内存不足 (OOM) 事件。对可用性要求很高的服务器应用程序(例如 ASP.NET 和 Exchange Server 2007)已通过 AppDomain 和进程回收达到了此目的。操作系统提供了非常强大的机制来清理内存和进程使用的大多数其他资源 — 所有这一切都在进程终止后完成。
就客户端而言,当内存压力达到即使很小的分配也会出现故障这种程度时,由于严重的超负荷和分页,会使整体系统进入一定程度上的无响应状态,导致用户宁愿去按重置按钮或者寻求任务管理器的帮助,也不愿意等待任何恢复代码的执行。从某种意义上说,用户的第一反应是手动执行 ASP.NET 或 Exchange 2007 会自动执行的同一个操作。
某些 OOM 甚至并不是由运行代码的任何特殊问题所引起的。运行在计算机上的其他进程或运行在该进程中的其他 AppDomain 可能会占用可用的资源池,并导致分配失败。从这种意义上说,应认为资源耗尽是异步的,因为它们在执行代码的任何时候都可能发生,并且它们可能依赖于运行代码外部和独立于运行代码的各种环境因素。
由于运行时可能会分配内存以执行与其自身运行相关的操作,因此该问题会变得更加严重。下面是几个发生在运行时的分配示例,它们在资源有限的环境中可能会发生故障:
- 装箱和取消装箱
- 延迟的类加载,直到第一次使用类为止
- 对 MarshalByRef 对象的远程操作
- 对字符串的某些操作
- 安全检查
- JITing 方法
虽然这仅仅是需要分配资源的运行时中很多内部操作的部分列表,但通过它,您应能了解为什么说预测和缓解任何特定分配失败的后果确实不实际。
在异步异常的列中,线程终止有特殊的作用。线程终止不是资源耗尽(例如 OOM 和 SO)导致的错误,但是它们同样可能随时发生。如果您从一个线程终止另一个线程,那么在被终止线程上引发异常的点是完全随机的。
堆栈溢出也有其自己的特性。堆栈空间是按线程保留的,并且会尽快提交,所以应该始终可以避免任何资源争用的情况。但是这存在一些问题。预测堆栈的大小为多少才能够满足每个应用程序的需要就像猜谜游戏一样。操作系统限制了每个线程的堆栈空间大小。如果由于回退的问题而导致线程的堆栈空间很少,那么通过结构化异常处理 (SEH) 呈现异常就存在一些问题。重新进入和递归排除了对方法所需的堆栈空间计算有限上限的可能。
简言之:实际上无法预测何时可能会发生 OutOfMemoryException、StackOverflowException 和 ThreadAbortException。因此,在大多数情况下,编写退出代码来试图从异步异常恢复是不切实际的。
您可能想知道,如果运行时无法保证复原异步异常,那么保证应用程序可靠是谁的责任呢?我们刚刚讨论过的 ASP.NET 示例暗示了答案。虽然应用程序代码负责处理常见的同步异常,但是它无法处理异步异常;异步异常必须由主机进程处理。如果是 ASP.NET,这便是包含会在内存消耗超过已知阈值时触发进程回收的逻辑的位置。
在其他更复杂的情况下,主机(例如 SQL ServerTM 2005)会利用 CLR 的宿主 API 来确定是终止运行事务的托管线程并回滚它,抑或是卸载 AppDomain,还是停止服务器上所有托管代码的执行。应用程序的默认策略是,虽然主机进程将被终止,但可以使用几个方法来接受和扩展此方法或覆盖此行为。有关 CLR 的宿主 API 的详细信息,请参阅 2006 年 8 月的“CLR 完全介绍”专栏 (msdn.microsoft.com/msdnmag/issues/06/08/CLRInsideOut)。
处理故障
到目前为止,您可能已经找到了解决此问题的关键。具有复原性的应用程序能够隔离单元中的操作,如果发生故障也不会影响其他单元。至今为止,已证明有三个模型可成功创建具有复原性的托管应用程序。
第一个模型是使进程本身成为故障的单元,并隔离一个或多个可随时终止及随时生成的工作进程中的托管代码执行。
第二个模型是保持两个以并行处理方式工作的冗余进程,其中一个处于活动状态,而另一个则处于休眠状态。一旦出现故障,休眠的进程会接管任务,并生成另一个休眠的进程充当备份,以防再次发生故障。
第三个模型是使 AppDomain 成为故障单元,并确保该进程绝不会受发生在托管代码或运行时中的任何故障的影响。
我们将更深入地探讨这三个方法,分析实现的成本以及完成设计的不同方法。
回收宿主进程
假设我们接受资源耗尽可能破坏承载 CLR 的进程这一事实。又假设系统是以这样一种方式构建的:操作隔离在一个或多个由主进程监控的子进程中,而主进程的任务是管理工作进程的生存期。在这种情况下,我们在提供可靠的系统方面就有了廉价而有效的解决方案。系统将不可复原,但是它的行为完全可恢复,并且可预测。
如果要处理大量的独立无状态请求(例如 ASP.NET 中的 Web 请求),并且希望隔离其处理的执行,那么此方法便是理想的选择。如果引发了异步异常,工作进程就会终止,并且不再继续向正在处理的请求提供服务,而是需要重新提交它们。但是,这样会导致此方法不适用于较长和成本高的操作,在此类操作中,重新提交作业的成本可能会太高。
但在提供运行托管代码的可靠服务方面,这仍然是成本最低的方法。您有效地利用了运行时的默认行为。
镜像的进程
从本地磁盘驱动器到整个服务器,IT 部门充分利用了一切冗余。如果磁盘或服务器发生故障,与第一个磁盘或服务器同步的第二个磁盘或服务器会迅速接管。
进程也可采用类似的方法。您可以设计在同一台计算机上运行两个进程副本的软件,每个副本都接收相同的输入并产生相同的输出。在主进程由于该特定进程中的临时错误而发生故障的情况下(与影响进程所有实例的可重现错误不同),此模型会提供一定的复原能力。在此情形下还应使用事务处理存储,以确保任何发生故障的请求都可安全地回滚。
美国国家航空航天局 (NASA) 对航天飞机上的计算机使用的模型与此类似。当生死攸关的操作均取决于计算机时,则必须要有某种程度的冗余。
在此情形下,NASA 实际上在只使用两台计算机时就遇到了问题:出现了两台计算机计算结果不一致的情况。哪一台的结果才是正确的呢?如果只使用两台计算机,您就无法在发生单一故障时判断哪一台的结果是正确的。因此,NASA 增加了第三台计算机,如果有两台计算机的结果相同但是第三台返回不同的结果,那么就认为第三台计算机已经损坏。
这个级别的冗余很好 — 除非三台计算机中的一台发生故障,因为那时您又回到了只有两台计算机可用的情况,即当再一次发生故障时您还是不知道哪一台的结果是正确的。因此 NASA 增加了第四台,然后说服自己增加了第五台。很明显,解决一对镜像进程中的非崩溃故障并不容易。
此方法还有另一个问题,那就是如果在同一台计算机上运行多个进程,并且其中一个耗尽了系统级资源,那么其他进程很可能也会同时需要同一资源。这可能会导致保留进程会以与第一个进程相同的方式发生故障。实际上,它们可能甚至会与另一个进程争用同一个资源。
回收部分进程
要获得高级别的复原能力,终止某个进程再重新启动它,或者故障转移到另一个进程,都无法实现。您真正需要做的是找到出现故障的应用程序部分,然后回收该部分。这要求将您应用程序进程的各个部分隔离成可回收的块。操作必须是无状态的,或者它们必须使用事务处理系统来确保不发生写入或确保它们会退回。此外,当回收一部分进程时,必须释放对所有资源的使用。
在考虑长期存在的服务器时,请考虑一下状态损坏和缺乏一致性。一致性可应用到几个不同的层。虽然简单的链接列表可能是一致的,但是复杂的数据结构就会需要其他固定条件。如果使用者具有一个固定条件,即链接列表中所有元素必须同时存储为哈希表中的值,那么链接列表的一致性就并不意味着应用程序是一致的。因此,必须将破坏固定条件的最小可能性作为问题来对待。如果发生异步异常,有多少状态可能会遭到损坏,服务器又如何从此损坏中复原呢?
要将应用程序分成可回收的块,必须将各个操作彼此隔离。发生异步异常时,可能已经引起状态损坏。要避免回收整个进程,必须封装更小部分的进程,包括发生故障的操作组和所有相关的状态信息。
什么是 AppDomain?
应用程序域(或缩写为 AppDomain)是针对托管代码的子进程隔离单位。大多数的程序集都可加载到 AppDomain 中。卸载 AppDomain 时,程序集通常也可卸载。
每个 AppDomain 都有自己的静态变量副本。虽然线程可以跨越 AppDomain 边界,但是它(几乎)不可能在不使用 .NET 远程处理或类似技术的情况下跨 AppDomain 边界启动通信。AppDomain 为您提供了相对稳定的边界来包含代码。
如果线程发生异步异常,则必须确定可能达到的损坏程度,以及如何复原该特定损坏级别。
状态损坏
状态损坏可以分为三个类别。第一个是本地状态,它包括本地变量和只由特定线程使用的堆对象。第二个是共享状态,它包括在 AppDomain 中的线程之间共享的任何内容,例如存储在静态变量中的对象。缓存通常属于这一类。第三个是整个进程范围、整个计算机范围或跨计算机的共享状态 — 文件、套接字、共享内存和分布式锁管理器都属于这一类。
异步异常可损坏的状态数量等于线程当前修改的最大状态数量。如果线程分配了一些临时对象,且没有将它们公开给其他线程,那么只有这些临时对象才可能被损坏。但是,如果线程正在写入共享状态,则该共享资源可能已损坏,那么其他线程就可能会遇到此损坏的状态。您应避免发生这种情况。在这种情况下,您可以终止 AppDomain 中所有其他线程,然后卸载 AppDomain。这样,异步异常就提升至 AppDomain,导致它卸载,并确保任何可能的损坏状态都会被丢弃。假如是事务处理存储(如数据库),那么此 AppDomain 回收会对本地和共享状态的损坏提供复原能力。
检测共享状态
推断线程是否在修改共享状态并不是一个简单的问题。堆栈上本地变量的值是否已在本地初始化,它是否作为参数传递到方法,或者它是否只引用了存储在静态变量中(或可从静态变量获得)的对象,这些内容在大多数代码中并不明显。
有关修改共享状态的细微需求来自并发空间:无论何时编辑静态变量,您的代码几乎都肯定会持有锁。因此,在每个线程上保持锁计数可以将线程是否可能正在编辑共享状态的信息传达给升级策略。通常在从共享状态读取时采用锁,并且在从共享状态读取时不会发生状态损坏(假设没有延迟初始化),这一点是事实。但是在不要求无法接受的其他用户规范级别时,这个最不利的启发式是我们认为最严格的。
您可以放心地使用联锁操作,因为它们只对原子级编辑一个共享状态而言是安全的。要知道它们是否成功非常简单 — 只要看它们是否执行。
升级策略
CLR 的升级策略有了一些发展。我们试图为用户代码提供一个在终止线程后执行清理的机会。因此 CLR 会试图在终止线程后到卸载 AppDomain 前的这段时间运行 finally 块和终结器。但是在用户对运行任意清理代码的愿望和主机可用性需求之间会造成紧张。您可以对运行 finally 块和终结器强制某种超时,如果在合理的时间内没有完成就终止它们。
可更棘手的问题是,如果在访问整个进程范围、整个计算机范围或跨计算机的状态时发生异步异常,要如何复原?我们将在稍后详细讨论这个问题。
复原到升级
如果由用户和库作者编写,升级策略也会对代码有所限制。对 SQL Server 而言,CLR 允许存储过程写入到托管代码中,但对它可以表达的内容有非常高的限制。就可伸缩性、可靠性和安全问题而言,SQL Server 中的用户代码不应启动或终止线程,而应最小化或完全避免共享状态,并且不能允许它访问某种类型的操作系统资源。但是,受信任的库(例如 .NET Framework)通常必须代表这个相对不受信任的用户代码访问这些资源。
CLR 提供了代码访问安全性作为第一道防线,以调整提供给用户代码的权限集。但是,CLR 没有包括所有有关资源类型的权限。为此,CLR 定义了 HostProtectionAttribute 属性,该属性可用来标记引发编程模型问题的方法,例如提供可以终止线程的能力。
对用户代码的这些限制实际上非常有用,因为通过限制用户代码直接访问操作系统资源(以及其他限制),用户代码就可以不必跟踪它对这些资源的使用情况。
在进程回收领域中,一旦某进程被终止后,操作系统就会释放整个计算机中被该进程使用的所有资源。如果需要 AppDomain 回收提供真正的复原能力,AppDomain 卸载必须提供相同级别的保证。由于 AppDomain 只是进程内的一个单元,所以提供对资源的访问的托管库必须填补操作系统在进程退出时执行清理的能力与 AppDomain 卸载需求之间的差异。
编写可靠的代码
AppDomain 卸载必须彻底。这就是编写对异步异常具有复原能力的库的指导方针。对于事务处理主机,例如保证其自身一致性的 SQL Server,有关用户和库代码的所有其他问题(如正确性、性能和可维护性)都从属于主机保证其可用性的能力。如果用户代码有偶尔引起故障的错误,只要服务器可以向前推进且不随着时间的推移而降级,就会一直运行。
彻底的 AppDomain 卸载和妥善的资源管理允许代码对抗 CLR 的升级策略,从而使您可以确定肯定有机会运行的代码以确保资源的一致性,或确保没有资源泄漏。在大多数情况下,异步异常应该早已发生或者无法避免 — 这些是代码从故障复原所需要的工具。对库编写者最重要的一点建议是使用 SafeHandle,了解其他几个可用的功能也非常重要,这样可让您完全明白为何要使用 SafeHandle。
选择可靠性级别
并不是所有的代码都同等重要。您应考虑一下特定代码块需要什么级别的可靠性。我们介绍的这些技术可能会增加开发成本,因此好的工程师应该确定对于某个代码块,什么级别的复原能力才是必要的。
您首先问问自己,在发生电源故障时,自己的代码应起到什么样的作用。作为起点,显然所有的代码必须能够在发生电源故障后重新启动并正常运行。即使客户端应用程序会停止运行并遇到数据损坏,它们仍然必须至少能够开始备份。
设计邮件服务器以确保在发生电源故障时它们不会丢失电子邮件,是一个更加棘手的问题。同样,控制核电站的软件必须能够容忍这类故障,而且要比基本生产应用程序具有更强的复原能力。确定您的应用程序属于何种级别应该不难,这对于在复原能力方面的投资决策会有所帮助。
对于大多数客户端应用程序,令人吃惊的回答是这方面的工作做得非常少。对大多数客户端应用程序来说,能够从异步异常中幸存下来已经很不错了。通常是终止进程再重新启动就足够了。使用 Windows Vista Restart Manager API 时,该方法可以帮助限制客户端应用程序崩溃时所丢失的状态量。Outlook 是一个很好的示例。如果 Outlook 2007 在 Windows Vista 上崩溃,它可以恢复并在正确位置重新打开所有窗口。如果 Outlook 崩溃时您正在撰写邮件,那么您可能只会丢失最后一两分钟的输入,而不是丢失整个邮件。
对于库来说,可靠性级别将由运行代码的最积极的主机决定。如果库由回收进程的主机使用,则可靠性需求会低于回收 AppDomain 的主机。但是,如果库允许访问资源但不使用我们介绍的技术,且代码在回收 AppDomain 的主机中使用,那么库最终会导致主机发生故障。
清理代码
可以使用 try/finally 块和终结器来清理资源。作为初步近似,这些工具为开发人员提供了一个将状态恢复到合理的一致性级别的简单方法。Try/finally 块(特定于语言的关键字,如 C#“using”语句)将确保在代码中明确的位置清理和释放资源,从而提高正确性和性能。
可是这种方法的问题是 CLR 无法轻易保证 finally 块中代码的完整性。虽然 CLR 在 finally 块中运行时试图避免终止线程,但是资源耗尽和由此引发的异步异常仍可能随时发生。
同样,我们无法确保给定的所有终结器都是完整的。此外,CLR 的升级策略允许主机在 finally 块和终结器中终止线程,以防 finally 块无限期地进入无限循环或块。
受约束的执行区域
受约束的执行区域(即 CER)是帮助代码保持一致性的可靠性基元。如果您希望通过拼命努力来保证一些限量的向前推进或者能够撤消对对象的更改,这是最好的选择。受约束的执行区域是一种从运行时运行代码的尽力尝试。使用 CER,该运行时会将任何 CLR 引发的故障提升至可预测的位置。这不保证代码会运行 — 您不能象仙女散花一样散布 CER 并期望您的代码奇迹般地运行 — 但是如果您编写的代码具有某些严格的约束,那么该代码就很有可能会运行至完成。
CER 是以三种形式公开的:
- 使用 ExecuteCodeWithGuaranteedCleanup,这是 try/finally 的堆栈溢出安全形式。
- 作为 try/finally 块,后面直接紧跟对 RuntimeHelpers.PrepareConstrainedRegions 的调用。在这种情况下,try 块不受到约束,但是该 try 的所有 catch、finally 和 fault 块都受到约束。
- 作为关键的终结器。在此,CriticalFinalizerObject 的任何子类都有一个终结器,在分配对象的实例之前做好积极准备。
(注意有一种特殊情况:SafeHandle 的 ReleaseHandle 方法,它是一种虚拟方法,在分配并从 SafeHandle 的关键终结器调用子类之前已做好积极准备。)
为了使您的代码有机会执行,CLR 会积极准备您的代码,这意味着它会为您的方法 JIT 可静态发现的调用图。如果您在 CER 中使用委托,则必须积极准备该委托的目标方法,最好是调用方法的 RuntimeHelpers.PrepareMethod()。此外,如果您使用 ngen,则可以用 PrePrepareMethodAttribute 来标记方法,以减少在 JIT 中提出请求的需要。这种积极准备将减少 CLR 引发的内存不足异常。
就线程终止而言,CLR 会对 CER 禁用它们。当且仅当 CLR 的主机(例如,任何使用 CLR 的 COM 承载接口来启动 CRL 的本机应用程序)希望可从堆栈溢出恢复的时候,CLR 还会检查某些堆栈。如果不是运行时本身的错误、堆损坏和硬件故障,这应该会消除您的代码所导致的所有 CLR 引发的异步异常。
可靠性约定
您可能已注意到,我一直在区分 CLR 引发的故障和其他所有故障。故障可在系统的任何级别发生,因为系统的每层对一致性都有不同的看法。假设有一个希望以排序顺序维护项目列表的应用程序。如果您使用未排序的集合,例如 List<T>,那么代码可能会如下所示:
public static void InsertItem<T>(List<T> list, T item) { // List<T> isn't sorted, but the app relies on it being sorted. list.Add(item); list.Sort(); }
现在假设 List<T> 上的所有方法都始终奇迹般地获得了成功。我们将使用假定的 ReliableList<T> 来表示此行为。假设 Add 从不需要增加该列表的大小,而 Sort 例程也始终正常运行(即使它对比较器和泛型类型 T 上的 CompareTo 方法进行了间接调用)。如果在调用 Add 完成之后到调用 Sort 之前的这段时间内,InsertItem 中发生了 ThreadAbortException,那么我们仍会遇到一致性问题。
从 ReliableList<T> 的角度看,该列表完全一致。列表的内部数据结构处于非常好的状态,因为没有半增加到集合中的额外项目。我们可以继续使用该列表,没有任何问题。但是,从应用程序的角度来看(请记住需要对列表排序),这个固定条件已经遭到了破坏,并且在编写方法时并未考虑从此问题恢复。
此情形说明在系统的各个级别中存在着一致性。这可通过可靠性约定传达给用户,即一种非常粗粒度的机制,用来声明发生异步异常时的损坏程度。在这种情况下,方法损坏了该列表,如果我们接受 Add 方法调用期间出现 OutOfMemoryException 的可能性,那么我们就必须将此方法标记成可能发生故障,如下所示:
[ReliabilityContract(Consistency.MayCorruptInstance, Cer.MayFail)] public static void InsertItem<T>(ReliableList<T> list, T item)
所有在 CER 中调用的代码都需要可靠性约定,这种约定主要是作为文档提供给开发人员,指示是否可能出现故障。在此,可靠性约定还可以更好地指出是参数还是“this”指针损坏,但是这实际上是为了在 InsertItem 方法的编写者和调用者之间展开一场可靠性讨论。在这种情况下,InsertItem<T> 的调用方会注意到,如果该方法失败,可能会导致实例损坏,因此他们会意识到需要缓解策略。
缓解故障的艰难旅程
受约束的执行区域提供了一些可以缓解代码(如 InsertItem)中故障的方法。这些选择的难易程度有所不同,并且如果代码在不同版本之间变动很大,有些技术就可能无法发挥作用。
最明显的方法就是尝试了解故障并设法避免它。在本示例中,可以将分配提升至能够从故障恢复的位置,例如首先给列表分配足够的容量。这会提升任何分配故障。但是,这要求您了解 InsertItem<T> 和 List<T>,以及它们是如何发生故障的。
此外,此方法不能解决线程终止问题。列表可能还尚未排序。此时,可以使用 CER 来确保 finally 块始终是排序的。不管 Add 是否发生故障,我们都通过保证它已排序来确保列表的一致性。这需要对我们假定的 ReliableList<T> 的 Sort 方法有稳固的可靠性保证。但是当它就绪之后,我们可以编写自己的代码,如图 1 所示,在 InsertItem 的可靠性约定中提供了更稳固的一致性保证。
请记住,这些方法在此示例的真正实现过程中并不起作用,因为 ReliableList<T> 实际并不存在。由于排序算法的特性,编写工作可能特别困难 — Sort 使用的任何比较器和 T 的 CompareTo 方法(非常可能)都需要具备可靠性。
但是还有另外一个方法,它与回收模型类似。如果您可以忍受性能下降,就可以复制数据,对副本进行更改,然后再对外公开一致的数据结构。下面是一种实现方法,包括了方法签名的折中方案:
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] public static void InsertItem<T>(ref List<T> list, T item) { // Forward progress List<T> copy = new List<T>(list); // expensive copy operation copy.Add(); copy.Sort(); // Expose the results list = copy; }
在此,我们可以使用真正的 List<T>,但是一定要注意,复制数据时性能会大大降低。此外,在此使用 ref 参数并不是真正最好的解决方案。但是,稳固的可靠性约定规定“不会损坏状态”,这通常是我们最需要的。
另一个方法是尝试向前推进并提供退出代码,以防发生故障。这类似于针对更改构造事务,并提供代码回滚事务中所做的更改。
何时使用稳固的可靠性约定
先前的示例说明了稳固的可靠性约定难以有效地实现。使用 CER 就像火箭科学,因为您要为随时随地可能发生的故障做好准备。请注意,对于公开为已注释 try/finally 块的 CER,try 块不是 CER,因此甚至可能会有多个赋值操作被异步异常中断。因此,CER 不适合大多数人 — 更好的选择是依赖主机的提升策略,或者回收整个应用程序(对于客户端应用程序来说)。
这意味着,真正需要 CER 的地方会处理计算机或整个进程范围的状态,例如共享内存段。在这种情况下,企业通常要求将这些应用程序编写为能够在代码的任何地方从电源故障中恢复,所以您不仅必须编写 CER,而且还必须特别留心应用程序中各个重要部分的一致性。这种复杂性就是您不希望把暑期实习编写的控制软件用于核电站的原因。
SafeHandle
您可能想知道,在发生异步异常时是否可以安全地从方法返回值。您无法安全地实现这一目的!如果您调用未加强的 P/Invoke 方法(如返回 IntPtr,然后将返回的值赋予本地变量的 CreateFile),则会生成两个不同的计算机指令,并且线程会在任何两个计算机指令之间终止。在这个时候,使用 IntPtr 表示操作系统句柄基本上是不可靠的。但是还有一个解决方案。
访问操作系统资源(例如文件、套接字或事件)时,您会获得该资源的句柄,而且该资源最终肯定会被释放。SafeHandle 会确保:如果您可以分配一个资源,就可以释放该资源。为了实现这个保证,SafeHandle 聚合了多个 CLR 功能。
可定义 SafeHandle 的子类,然后提供 ReleaseHandle 方法的实现。此方法是 CER,从 SafeHandle 的关键终结器调用。假设您的 ReleaseHandle 方法服从所有的 CER 规则,就可以保证,如果能够成功地分配本机资源的实例,ReleaseHandle 方法就有机会运行。
SafeHandle 还提供了一些重要的安全性功能。它可抵御句柄回收攻击,这是通过确保当一个线程在积极使用一个句柄时,另一个线程无法释放该句柄来实现的。它还可防止使用句柄的类和它自己的终结器之间微妙的竞争情况。SafeHandle 还与 P/Invoke 封送层集成。对于任何返回或使用句柄的方法,都可以用 SafeHandle 的子类来替换该句柄。例如,CreateFile 会返回 SafeFileHandle,而 WriteFile 会将 SafeFileHandle 用作它的第一个参数。
使用锁
必须对适当类型的对象应用锁。想象一下扩展到调用 Monitor.Enter(Object) 的 C# 锁关键字,在 finally 块内后跟 Monitor.Exit(Object)。锁应该具有强烈的标识感 — 取消装箱的值类型不符合需要,因为在每次将它们作为 Object 传递时都会将其装箱。但是有时候 CLR 也会跨 AppDomain 边界共享某些类型。在 Strings、Type 对象、CultureInfo 实例以及 byte[] 上应用锁可能会最终跨 AppDomain 边界应用锁。同样,从 MarshalByRefObject 实现的外面锁定该类的任何子类可能会锁定透明代理,而不是锁定正确 appdomain 中的实际对象,这意味着您可能没有应用正确的锁!在本机编写的代码中,如果发生异步异常,释放锁的 finally 块将不会运行,同时可能还会引起其他线程无限期地阻塞。
不要定义自己的锁,除非您是内存模型和并发方面的真正专家,而且已证明需要更好的锁。除了明显的缺陷(例如可报警等待和如何在超线程 CPU 上旋转)以外,您的锁还必须与 CLR 的提升策略合作,这需要随时了解每个线程是否都持有锁。Thread.BeginCriticalRegion 和 EndCriticalRegion 可以帮助 CLR 确定获得和释放第三方锁的时机。
硬 OOM 条件和软 OOM 条件
对于内存不足错误,除了 AppDomain 卸载之外,还有另外一种缓解方法。MemoryFailPoint 类会尝试预测内存分配是否会失败。为 X MB 的内存分配 MemoryFailPoint,在此 X 表示处理一个请求的预期附加工作集的上限。然后处理该请求,并调用 MemoryFailPoint 上的 Dispose。
如果没有足够的可用内存,构造函数就会引发 InsufficientMemoryException,这是用来表示软 OOM 概念的不同异常类型。应用程序可使用它根据可用内存来调节自己的性能。异常是在实际分配内存之前引发的,此时尚未发生损坏。因此,这表示没有发生共享状态损坏,因此就没有必要让提升策略介入。
就保留或提交内存的物理页面而言,MemoryFailPoint 不保留内存,所以此方法并不可靠 — 可能会与进程中的其他堆分配展开竞争。但是,此方法确实维护了内部整个进程范围的保留计数,以跟踪在进程中使用 MemoryFailPoint 的所有线程。我们相信,这可以降低硬 OOM 需要调用框架提升策略的频率。