.NET中的特殊异常以及如何准备它们

.NET中的各种异常都有其自身的特征,了解它们可能非常有用。 如何欺骗CLR? 如何通过捕获StackOverflowException保持运行时状态? 似乎无法捕获哪些例外,但是如果您确实愿意,可以吗?



删节后,来自我们的DotNext 2018 Piter会议的Eugene( epeshk )Peshkov的报告抄本,他谈到了异常的这些特征和其他特征。



你好 我叫尤金。 我为SKB Kontur工作,开发主机系统并为Windows部署应用程序。 最重要的是,我们有许多产品团队来编写自己的服务并与我们托管。 我们为他们提供了轻松,简单的解决方案,以应对各种基础架构任务。 例如,监视系统资源的消耗或完成对服务的复制。

有时,事实证明,托管在我们系统上的应用程序会崩溃。 我们已经看到了应用程序如何在运行时崩溃的多种方式。 其中一种方法是抛出一些意外且令人着迷的异常。

今天,我将讨论.NET中的异常功能。 我们在生产中遇到了其中一些功能,并且在实验过程中遇到了其中一些功能。

计划


  1. .NET异常行为
  2. Windows异常处理和黑客

对于Windows,以下所有内容均适用。 所有示例都在完整的.NET 4.7.1框架的最新版本上进行了测试。 .NET Core也将有一些引用。

访问冲突


错误的内存操作过程中会发生此异常。 例如,如果应用程序尝试访问它无权访问的内存区域。 例外是低级别,通常,如果发生这种情况,将需要很长时间的调试。

让我们尝试使用C#获得此异常。 为此,我们将字节42写入地址1000(我们假设1000是一个相当随机的地址,我们的应用程序很可能无法访问它)。

try { Marshal.WriteByte((IntPtr) 1000, 42); } catch (AccessViolationException) { ... } 

WriteByte正是我们所需要的:它将字节写入给定地址。 我们希望此调用引发AccessViolationException。 该代码确实将引发此异常,它将能够处理并且该应用程序将继续运行。 现在让我们稍微更改一下代码:

 try { var bytes = new byte[] {42}; Marshal.Copy(bytes, 0, (IntPtr) 1000, bytes.Length); } catch (AccessViolationException) { ... } 

如果使用Copy方法而不是WriteByte并将字节42复制到地址1000,然后使用try-catch,则无法捕获AccessViolation。 同时,控制台上将显示一条消息,指出由于未处理的AccessViolationException而终止了该应用程序。

 Marshal.Copy(bytes, 0, (IntPtr) 1000, bytes.Length); Marshal.WriteByte((IntPtr) 1000, 42); 

事实证明,我们有两行代码,而第一行使用AccessViolation使整个应用程序崩溃,而第二行则抛出相同类型的已处理异常。 为了理解为什么会发生这种情况,我们将研究如何从内部安排这些方法。

让我们从Copy方法开始。

 static void Copy(...) { Marshal.CopyToNative((object) source, startIndex, destination, length); } [MethodImpl(MethodImplOptions.InternalCall)] static extern void CopyToNative(object source, int startIndex, IntPtr destination, int length); 

Copy方法唯一要做的就是调用在.NET内部实现的CopyToNative方法。 如果我们的应用程序仍然崩溃,并且某处发生异常,则只能在CopyToNative内部发生。 从这里我们可以进行第一个观察:如果.NET代码称为本机代码,并且AccessViolation发生在其中,则由于某种原因,.NET代码无法处理此异常。

现在我们将了解为什么可以使用WriteByte方法处理AccessViolation。 让我们看一下该方法的代码:

 unsafe static void WriteByte(IntPtr ptr, byte val) { try { *(byte*) ptr = val; } catch (NullReferenceException) {     // this method is documented to throw AccessViolationException on any AV throw new AccessViolationException(); } } 

此方法在托管代码中完全实现。 它使用C#指针将数据写入所需的地址,并且还捕获NullReferenceException。 如果NRE被拦截,则抛出AccessViolationException。 因此有必要根据规范 。 在这种情况下,将处理throw结构所引发的所有异常。 因此,如果在WriteByte内部执行代码期间发生NullReferenceException,则可以捕获AccessViolation。 在我们的情况下,访问地址1000而不是地址0时会发生NRE吗?

我们直接使用C#指针重写代码,然后看到访问非零地址时实际上会抛出NullReferenceException:

 *(byte*) 1000 = 42; 

要了解为什么会发生这种情况,我们需要记住该过程的内存是如何工作的。 在过程存储器中,所有地址都是虚拟的。 这意味着该应用程序具有很大的地址空间,并且只有一部分页面显示在实际的物理内存中。 但是有一个特点:前64 KB的地址永远不会映射到物理内存,也不会分配给应用程序。 Rantime .NET知道并使用它。 如果在托管代码中发生AccessViolation,则运行时将检查访问内存中的哪个地址并生成适当的异常。 对于从0到2 ^ 16的地址-NullReference,对于所有其他地址-AccessViolation。



让我们看看为什么不仅在访问零地址时抛出NullReference。 假设您正在访问引用类型的对象的字段,并且对该对象的引用为null:



在这种情况下,我们希望得到一个NullReferenceException。 对对象字段的访问发生在相对于该对象地址的偏移处。 事实证明,我们将转向一个足够接近零的地址(请记住,指向我们原始对象的链接为零)。 通过这种运行时行为,无需额外验证对象本身的地址即可获得预期的异常。

但是,如果我们转到对象的字段,并且该对象本身占用的内存超过64 KB,会发生什么情况?



在这种情况下可以获取AccessViolation吗? 让我们做一个实验。 让我们创建一个非常大的对象,我们将引用它的字段。 一个字段位于对象的开头,第二个字段位于结尾:



这两个方法都将抛出NullReferenceException。 不会发生AccessViolationException。
让我们看看将为这些方法生成的指令。 在第二种情况下,JIT编译器添加了一个附加的cmp指令,该指令可访问对象本身的地址,从而以零地址调用AccessViolation,运行时将其转换为NullReferenceException。

值得注意的是,对于本实验,将数组用作大对象是不够的。 怎么了 将这个问题留给读者,在评论中写点子:)

让我们总结一下AccessViolation的实验。



AccessViolationException的行为根据异常发生的位置(在托管代码或本机中)而有所不同。 另外,如果托管代码中发生异常,则将检查对象的地址。

问题是:我们可以处理在本机代码或托管代码中发生但未转换为NullReference且未使用throw抛出的AccessViolationException吗? 有时这是一个有用的功能,尤其是在处理不安全的代码时。 该问题的答案取决于.NET的版本。



在.NET 1.0中,根本没有AccessViolationException。 所有链接都被视为有效或为空。 到.NET 2.0时代,很明显,没有直接使用内存的方法-没办法,AccessViolation出现了,而且它是可处理的。 在4.0及更高版本中,它仍然可以工作,但是处理起来却不是那么简单。 要捕获此异常,您现在需要使用HandleProcessCorruptedStateException属性标记catch块所在的方法。 显然,开发人员之所以这样做,是因为他们认为AccessViolationException并不是常规应用程序中应捕获的异常。
另外,为了向后兼容,可以使用运行时设置:

  • legacyNullReferenceExceptionPolicy返回.NET 1.0行为-所有AV都变为NRE
  • legacyCorruptedStateExceptionsPolicy返回.NET 2.0行为-拦截了所有AV

在.NET中,根本不处理Core AccessViolation。

在我们的生产中有这样一种情况:



在.NET 4.7.1下构建的应用程序使用在.NET 3.5下构建的共享代码库。 该库中有一个助手来执行定期操作:

 while (isRunning) { try { action(); } catch (Exception e) { log.Error(e); } WaitForNextExecution(... ); } 

我们将应用程序中的操作传递给了此帮助器。 碰巧他使AccessViolation崩溃了。 结果,我们的应用程序不断记录AccessViolation,而不是因为 3.5以下库中的代码可以捕获它。 应该注意的是,侦听并不取决于运行应用程序的运行时版本,而是取决于构建应用程序的TargetFramework及其依赖项。

总结一下。 AccessVilolation处理取决于其起源(本机或托管代码)以及TargetFramework和运行时设置。

线程中止


有时在代码中,您需要停止线程之一。 为此,可以使用thread.Abort();。

 var thread = new Thread(() => { try { ... } catch (ThreadAbortException e) { ... Thread.ResetAbort(); } }); ... thread.Abort(); 

在停止的线程中调用Abort方法时,将引发ThreadAbortException。 让我们分析一下它的功能。 例如,如下代码:

 var thread = new Thread(() => { try { … } catch (ThreadAbortException e) { … } }); ... thread.Abort(); 

绝对等于:

 var thread = new Thread(() => { try { ... } catch (ThreadAbortException e) { ... throw; } }); ... thread.Abort(); 

如果仍然需要处理ThreadAbort并在已停止的线程中执行其他一些操作,则可以使用Thread.ResetAbort()方法; 它停止了停止流的过程,并且异常停止将更高的堆栈向上抛出。 重要的是要了解thread.Abort()方法本身不能保证任何内容-已停止线程中的代码可能会阻止它停止。

thread.Abort()的另一个功能是,如果它在catch中并最终阻塞,它将无法中断代码。

在框架代码中,您经常可以找到try块为空且所有逻辑最终都在其中的方法。 这样做只是为了防止ThreadAbortException抛出此代码。

另外,对thread.Abort()方法的调用等待ThreadAbortException的抛出。 结合这两个事实,可以得到thread.Abort()方法可以阻止调用线程。

 var thread = new Thread(() => {   try { }       catch { } // <-- No ThreadAbortException in catch       finally { // <-- No ThreadAbortException in finally           Thread.Sleep(- 1); } }); thread.Start(); ... thread.Abort(); // Never returns 

实际上,在使用using时可能会遇到这种情况。 它部署在try / final中,在final内调用Dispose方法。 它可以任意复杂,包含事件处理程序,使用锁。 如果在运行时调用thread.Abort,则Dispose-thread.Abort()将等待它。 因此,我们几乎从零开始就获得了一把锁。

在.NET Core中,thread.Abort()方法引发PlatformNotSupportedException。 而且我认为这非常好,因为它不是使用thread.Abort()而是使用非侵入性方法来停止代码执行,例如使用CancellationToken来激励。

内存不足


如果机器上的内存少于所需的内存,则可以获取此异常。 或者当我们遇到32位进程的限制时。 但是,即使计算机具有大量可用内存,并且该过程是64位的,您也可以获取它。

 var arr4gb = new int[int.MaxValue/2]; 

上面的代码将抛出OutOfMemory。 事实是,默认情况下,不允许使用大于2 GB的对象。 可以通过在App.config中设置gcAllowVeryLargeObjects来解决此问题。 在这种情况下,将创建一个4 GB的阵列。

现在让我们尝试创建更多数组。

 var largeArr = new int[int.MaxValue]; 

现在,即使gcAllowVeryLargeObjects也无济于事。 这是因为.NET 对数组中的最大索引限制 。 此限制小于int.MaxValue。

最大数组索引:

  • 字节数组-0x7FFFFFC7
  • 其他阵列-0X7F E FFFFF

在这种情况下,将发生OutOfMemoryException,尽管实际上我们遇到了数据类型限制,而不是内存不足。

有时,.NET框架中的托管代码会明确丢弃OutOfMemory:


这是string.Concat方法的实现。 如果结果字符串的长度大于int.MaxValue,则会立即引发OutOfMemoryException。

让我们继续讨论当内存实际用完时出现OutOfMemory的情况。

 LimitMemory(64.Mb()); try { while (true)   list.Add(new byte[size]); } catch (OutOfMemoryException e) { Console.WriteLine(e); } 

首先,我们将进程的内存限制为64 MB。 接下来,在循环中,选择新的字节数组,将它们保存到某些表中,以使GC不会收集它们,并尝试捕获OutOfMemory。

在这种情况下,任何事情都会发生:

  • 异常处理
  • 进程将下降
  • 让我们赶上来,但是异常会再次崩溃
  • 让我们进入陷阱,但是StackOverflow将会崩溃

在这种情况下,该程序将是完全不确定的。 让我们分析所有选项:

  1. 可以处理异常。 在.NET内部,没有什么可以阻止您处理OutOfMemoryException。
  2. 该过程可能会失败。 不要忘记我们有一个托管应用程序。 这意味着它不仅在内部执行我们的代码,还在运行时代码中执行。 例如,GC。 因此,当运行时想要为其分配内存但无法执行此操作时,可能会发生这种情况,那么我们将无法捕获异常。
  3. 让我们进入陷阱,但是异常将再次崩溃。 在catch内,我们还会在需要内存的地方进行工作(我们将异常输出到控制台),这可能会导致新的异常。
  4. 让我们开始讨论,但是StackOverflow将崩溃。 调用WriteLine方法时会发生StackOverflow本身,但是这里没有堆栈溢出,但是会发生另一种情况。 让我们更详细地分析它。



在虚拟内存中,页面不仅可以映射到物理内存,还可以保留。 如果该页面被保留,则应用程序会指出它将要使用它。 如果页面已经映射到实际内存或交换,则称为“已提交”(committed)。 堆栈使用此功能将内存拆分为保留和提交。 看起来像这样:



事实证明,我们调用了WriteLine方法,该方法在堆栈上占据了一些位置。 事实证明,所有已提交的内存都已结束,这意味着操作系统此刻应该在堆栈上获取另一个保留页,并将其映射到实际的物理内存,该内存已由字节数组填充。 这导致StackOverflow例外。

下面的代码将允许您在流的开头立即将所有内存提交到堆栈。

 new Thread(() => F(), 4*1024*1024).Start(); 

或者,您可以使用disableCommitThreadStack 运行时设置 。 需要禁用它,以便线程堆栈提前提交。 值得注意的是,文档中描述的和实际观察到的默认行为是不同的。



堆栈溢出


让我们仔细看一下StackOverflowException。 让我们看两个代码示例。 在其中一个中,我们运行无限递归,这导致堆栈溢出,在第二个中,我们仅使用throw抛出此异常。

 try { InfiniteRecursion(); } catch (Exception) { ... } 

 try { throw new StackOverflowException(); } catch (Exception) { ... } 

由于处理了所有使用throw引发的异常,因此在第二种情况下,我们将捕获该异常。 在第一种情况下,一切都变得更加有趣。 转到MSDN

“您不能捕获堆栈溢出异常,因为异常处理代码可能需要堆栈。”
MSDN

它在这里说,我们将无法捕获StackOverflowException,因为拦截本身可能需要已经结束的额外堆栈空间。

为了以某种方式防止此异常,我们可以执行以下操作。 首先,您可以限制递归的深度。 其次,可以使用RuntimeHelpers类的方法:

RuntimeHelpers.EnsureSufficientExecutionStack();

  • “确保剩余的堆栈空间足够大,可以执行平均的.NET Framework函数。” -MSDN
  • InsufficientExecutionStackException
  • 512 KB-x86,AnyCPU,2 MB-x64(堆栈大小的一半)
  • 64/128 KB-.NET Core
  • 仅检查堆栈地址空间


此方法的文档说,它检查堆栈上是否有足够的空间来执行一般的 .NET函数。 但是平均函数是多少? 实际上,在.NET Framework中,此方法验证其大小的至少一半是否在堆栈上可用。 在.NET Core中,它将免费检查64K。

一个类似的东西也出现在.NET Core中:RuntimeHelpers.TryEnsureSufficientExecutionStack(),它返回布尔值,而不是引发异常。

C#7.2引入了在不使用不安全代码的情况下一起使用Span和stackallock的功能。 也许正因为如此,在代码中将更频繁地使用stackalloc,并且在使用它时有一种保护自己免受StackOverflow影响的方法,这是很有用的,它可以选择在哪里分配内存。 作为这种方法,提出一种方法来验证在栈trystackalloc构造上分配的可能性

 Span<byte> span; if (CanAllocateOnStack(size)) span = stackalloc byte[size]; else span = new byte[size]; 

返回MSDN上的StackOverflow文档

相反,当普通应用程序中发生堆栈溢出时,公共语言运行时(CLR)将终止该进程。”
MSDN

如果在StackOverflow期间存在“正常”应用程序,那么是否存在不正常的非正常应用程序? 为了回答这个问题,您将必须从托管应用程序级别下降到CLR级别。



承载CLR的应用程序可以更改默认行为,并指定CLR 卸载发生异常的应用程序域 ,但可以继续该过程。” -MSDN
StackOverflowException-> AppDomainUnloadedException

承载CLR的应用程序可以重新定义堆栈溢出的行为,以便代替完成整个过程,而将应用程序域卸载到发生此溢出的流中。 因此,我们可以将StackOverflowException转换为AppDomainUnloadedException。

启动托管应用程序时,.NET运行时将自动启动。 但是您可以选择其他方式。 例如,编写一个非托管应用程序(使用C ++或其他语言),该应用程序将使用特殊的API来提高CLR并启动我们的应用程序。 内部运行CLR的应用程序称为CLR主机。 通过编写它,我们可以在运行时配置许多东西。 例如,替换内存管理器和线程管理器。 我们在生产中使用CLR主机来避免交换内存页面。

以下代码配置CLR主机,以便在StackOverflow期间卸载AppDomain(C ++):

 ICLRPolicyManager *policyMgr; pCLRControl->GetCLRManager(IID_ICLRPolicyManager, (void**) (&policyMgr)); policyMgr->SetActionOnFailure(FAIL_StackOverflow, eRudeUnloadAppDomain); 

这是逃避StackOverflow的好方法吗? 大概不是。 首先,我们不得不编写我们不想做的C ++代码。 其次,我们必须更改C#代码,以便可以引发StackOverflowException的函数在单独的AppDomain和单独的线程中执行。 我们的代码将立即变成这样的面条:

 try { var appDomain = AppDomain.CreateDomain("..."); appDomain.DoCallBack(() => { var thread = new Thread(() => InfiniteRecursion()); thread.Start(); thread.Join(); }); AppDomain.Unload(appDomain); } catch (AppDomainUnloadedException) { } 

为了调用InfiniteRecursion方法,我们编写了很多行。 第三,我们开始使用AppDomain。 这几乎保证了一系列新问题。 包括例外。 考虑一个例子:

 public class CustomException : Exception {} var appDomain = AppDomain.CreateDomain( "..."); appDomain.DoCallBack(() => throw new CustomException()); System.Runtime.Serialization.SerializationException: Type 'CustomException' is not marked as serializable. at System.AppDomain.DoCallBack(CrossAppDomainDelegate callBackDelegate) 

由于我们的异常未标记为可序列化,因此我们的代码将带有Seri​​alizationException。 为了解决此问题,仅用Serializable属性标记异常是不够的,我们仍然需要实现一个额外的构造函数以进行序列化。

 [Serializable] public class CustomException : Exception { public CustomException(){} public CustomException(SerializationInfo info, StreamingContext ctx) : base(info, context){} } var appDomain = AppDomain.CreateDomain("..."); appDomain.DoCallBack(() => throw new CustomException()); 

事实证明,它并不是很漂亮,所以我们走得更远-达到操作系统和黑客的水平,这不应在生产中使用。

Seh / veh




请注意,虽然托管例外在托管和CLR之间运行,但SEH例外在CLR和Windows之间飞行。

SEH-结构化异常处理

  • Windows异常处理引擎
  • 统一的软件和硬件异常处理
  • 在SEH之上实现的C#异常

SEH是Windows中的异常处理机制,它使您能够同样统一地处理任何异常,例如,来自处理器级别或与应用程序本身的逻辑相关联的异常。

Rantime .NET知道SEH异常并将其转换为托管异常:

  • EXCEPTION_STACK_OVERFLOW->崩溃
  • EXCEPTION_ACCESS_VIOLATION-> AccessViolationException
  • EXCEPTION_ACCESS_VIOLATION-> NullReferenceException
  • EXCEPTION_INT_DIVIDE_BY_ZERO-> DivideByZeroException
  • 未知的SEH例外-> SEHException

我们可以通过WinApi与SEH进行交互。

 [DllImport("kernel32.dll")] static extern void RaiseException(uint dwExceptionCode, uint dwExceptionFlags, uint nNumberOfArguments,IntPtr lpArguments); // DivideByZeroException RaiseException(0xc0000094, 0, 0, IntPtr.Zero); // Stack overflow RaiseException(0xc00000fd, 0, 0, IntPtr.Zero); 

, throw SEH.

 throw -> RaiseException(0xe0434f4d, ...) 

, CLR-exception , , .

VEH — , SEH, , . SEH try-catch, VEH . , . VEH — , SEH- , .



, SEH- EXCEPTION_STACK_OVERFLOW , .NET .

VEH WinApi:

 [DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr AddVectoredExceptionHandler(IntPtr FirstHandler,  VECTORED_EXCEPTION_HANDLER VectoredHandler); delegate VEH PVECTORED_EXCEPTION_HANDLER(ref EXCEPTION_POINTERS exceptionPointers); public enum VEH : long { EXCEPTION_CONTINUE_SEARCH = 0, EXCEPTION_EXECUTE_HANDLER = 1, EXCEPTION_CONTINUE_EXECUTION = -1 } delegate VEH PVECTORED_EXCEPTION_HANDLER(ref EXCEPTION_POINTERS exceptionPointers); [StructLayout(LayoutKind.Sequential)] unsafe struct EXCEPTION_POINTERS { public EXCEPTION_RECORD* ExceptionRecord; public IntPtr Context; } delegate VEH PVECTORED_EXCEPTION_HANDLER(ref EXCEPTION_POINTERS exceptionPointers); [StructLayout(LayoutKind.Sequential)] unsafe struct EXCEPTION_RECORD { public uint ExceptionCode; ... } 

Context . EXCEPTION_RECORD ExceptionCode . , CLR . :

 static unsafe VEH Handler(ref EXCEPTION_POINTERS e) { if (e.ExceptionRecord == null) return VEH. EXCEPTION_CONTINUE_SEARCH; var record = e. ExceptionRecord; if (record->ExceptionCode != ExceptionStackOverflow) return VEH. EXCEPTION_CONTINUE_SEARCH; record->ExceptionCode = 0x01234567; return VEH. EXCEPTION_EXECUTE_HANDLER; } 

, HandleSO, , StackOverflowException ( WinApi ).

 HandleSO(() => InfiniteRecursion()) ; static T HandleSO<T>(Func<T> action) { Kernel32. AddVectoredExceptionHandler(IntPtr.Zero, Handler); Kernel32.SetThreadStackGuarantee(ref size); try { return action(); } catch (Exception e) when ((uint) Marshal. GetExceptionCode() == 0x01234567) {} return default(T); } HandleSO(() => InfiniteRecursion()); 

SetThreadStackGuarantee. StackOverflow.

. , .

, , HandleSO ?

 HandleSO(() => InfiniteRecursion()); HandleSO(() => InfiniteRecursion()); 

AccessViolationException. .


. , Guard page. – STATUS_GUARD_PAGE_VIOLATION, Guard page . , – stack-pointer , . — AccessViolationException. StackOverflow – c – _resetstkoflw C (msvcrt.dll).

 [DllImport("msvcrt.dll")] static extern int _resetstkoflw(); 

AccessViolationException .NET Core Windows, . , .NET Core VEH AccessViolation. AddVectoredExceptionHandler:

 Kernel32.AddVectoredExceptionHandler(FirstHandler: (IntPtr) 1, handler); 

, :

  • , ;
  • ;
  • ;
  • .NET , .

参考文献



Dotnext 2016 Moscow — Adam Sitnik — Exceptional Exceptions in .NET
DotNetBook: Exceptions
.NET Inside Out Part 8 — Handling Stack Overflow Exception in C# with VEH — StackOverflow.

22-23 DotNext 2018 Moscow « : » . , , . , — . !

Source: https://habr.com/ru/post/zh-CN426045/


All Articles