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

删节后,来自我们的
DotNext 2018 Piter会议的Eugene(
epeshk )Peshkov的报告抄本,他谈到了异常的这些特征和其他特征。
你好 我叫尤金。 我为SKB Kontur工作,开发主机系统并为Windows部署应用程序。 最重要的是,我们有许多产品团队来编写自己的服务并与我们托管。 我们为他们提供了轻松,简单的解决方案,以应对各种基础架构任务。 例如,监视系统资源的消耗或完成对服务的复制。
有时,事实证明,托管在我们系统上的应用程序会崩溃。 我们已经看到了应用程序如何在运行时崩溃的多种方式。 其中一种方法是抛出一些意外且令人着迷的异常。
今天,我将讨论.NET中的异常功能。 我们在生产中遇到了其中一些功能,并且在实验过程中遇到了其中一些功能。
计划
- .NET异常行为
- 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) {
此方法在托管代码中完全实现。 它使用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 { }
实际上,在使用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将会崩溃
在这种情况下,该程序将是完全不确定的。 让我们分析所有选项:
- 可以处理异常。 在.NET内部,没有什么可以阻止您处理OutOfMemoryException。
- 该过程可能会失败。 不要忘记我们有一个托管应用程序。 这意味着它不仅在内部执行我们的代码,还在运行时代码中执行。 例如,GC。 因此,当运行时想要为其分配内存但无法执行此操作时,可能会发生这种情况,那么我们将无法捕获异常。
- 让我们进入陷阱,但是异常将再次崩溃。 在catch内,我们还会在需要内存的地方进行工作(我们将异常输出到控制台),这可能会导致新的异常。
- 让我们开始讨论,但是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)
由于我们的异常未标记为可序列化,因此我们的代码将带有SerializationException。 为了解决此问题,仅用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);
, 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);
, :
参考文献
→
→
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 « : » . , , . , — . !