问题可以在递归方法中使用try-catch-throw块而不使用
Lazy<T>
来重现。 您已经注意到了行为的来源。 现在让我解释一下为什么,因为这没有意义,对吧? 这没有意义,因为异常被捕获然后立即重新抛出,所以堆栈应该缩小,对吧? 下面是测试:
internal class Program
{
private int _maxDepth;
[MethodImpl(MethodImplOptions.NoInlining)]
private int Calculate(int depth)
{
try
{
Console.WriteLine("In try at depth {0}: stack frame count = {1}", depth, new StackTrace().FrameCount);
if (depth >= _maxDepth)
throw new InvalidOperationException("Max. recursion depth reached.");
return Calculate2(depth + 1);
}
catch
{
Console.WriteLine("In catch at depth {0}: stack frame count = {1}", depth, new StackTrace().FrameCount);
throw;
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private int Calculate2(int depth) => Calculate(depth);
public void Run()
{
try
{
_maxDepth = 10;
Calculate(0);
}
catch (Exception e)
{
Console.WriteLine("Finished with " + e.GetType());
}
}
public static void Main() => new Program().Run();
}
看起来验证了那个假设:
In try at depth 0: stack frame count = 3
In try at depth 1: stack frame count = 5
In try at depth 2: stack frame count = 7
In try at depth 3: stack frame count = 9
In try at depth 4: stack frame count = 11
In try at depth 5: stack frame count = 13
In try at depth 6: stack frame count = 15
In try at depth 7: stack frame count = 17
In try at depth 8: stack frame count = 19
In try at depth 9: stack frame count = 21
In try at depth 10: stack frame count = 23
In catch at depth 10: stack frame count = 23
In catch at depth 9: stack frame count = 21
In catch at depth 8: stack frame count = 19
In catch at depth 7: stack frame count = 17
In catch at depth 6: stack frame count = 15
In catch at depth 5: stack frame count = 13
In catch at depth 4: stack frame count = 11
In catch at depth 3: stack frame count = 9
In catch at depth 2: stack frame count = 7
In catch at depth 1: stack frame count = 5
In catch at depth 0: stack frame count = 3
Finished with System.InvalidOperationException
好的...不,事情并不那么简单。
.NET异常是建立在Windows结构化异常处理(SEH)之上的,这是一个复杂的东西。
如果您想了解详细信息,有两篇文章需要阅读。它们已经过时,但与您的问题相关的部分仍然准确:
但让我们集中精力解决手头的问题,即堆栈展开。
以下是第一篇文章中关于堆栈展开发生的情况(重点在我这里):
另一种取消操作是实际弹出CPU堆栈。这种情况并不像弹出SEH记录那样急切。在X86上,EBP用作包含SEH的方法的帧指针。ESP始终指向堆栈顶部。在堆栈实际取消之前,所有处理程序都在故障异常帧的顶部执行。因此,当第一次或第二次调用处理程序时,堆栈实际上会增长。将EBP设置为包含过滤器或最后一个子句的方法的帧,以便该方法的局部变量处于范围内。
直到执行捕获“except”子句时,才会实际弹出堆栈。让我们修改我们先前的测试程序来检查这一点:
internal class Program
{
private int _maxDepth;
private UIntPtr _stackStart;
[MethodImpl(MethodImplOptions.NoInlining)]
private int Calculate(int depth)
{
try
{
Console.WriteLine("In try at depth {0}: stack frame count = {1}, stack offset = {2}",depth, new StackTrace().FrameCount, GetLooseStackOffset());
if (depth >= _maxDepth)
throw new InvalidOperationException("Max. recursion depth reached.");
return Calculate2(depth + 1);
}
catch
{
Console.WriteLine("In catch at depth {0}: stack frame count = {1}, stack offset = {2}", depth, new StackTrace().FrameCount, GetLooseStackOffset());
throw;
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private int Calculate2(int depth) => Calculate(depth);
public void Run()
{
try
{
_stackStart = GetSomePointerNearTheTopOfTheStack();
_maxDepth = 10;
Calculate(0);
}
catch (Exception e)
{
Console.WriteLine("Finished with " + e.GetType());
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static unsafe UIntPtr GetSomePointerNearTheTopOfTheStack()
{
int dummy;
return new UIntPtr(&dummy);
}
private int GetLooseStackOffset() => (int)((ulong)_stackStart - (ulong)GetSomePointerNearTheTopOfTheStack());
public static void Main() => new Program().Run();
}
这是结果:
In try at depth 0: stack frame count = 3, stack offset = 384
In try at depth 1: stack frame count = 5, stack offset = 752
In try at depth 2: stack frame count = 7, stack offset = 1120
In try at depth 3: stack frame count = 9, stack offset = 1488
In try at depth 4: stack frame count = 11, stack offset = 1856
In try at depth 5: stack frame count = 13, stack offset = 2224
In try at depth 6: stack frame count = 15, stack offset = 2592
In try at depth 7: stack frame count = 17, stack offset = 2960
In try at depth 8: stack frame count = 19, stack offset = 3328
In try at depth 9: stack frame count = 21, stack offset = 3696
In try at depth 10: stack frame count = 23, stack offset = 4064
In catch at depth 10: stack frame count = 23, stack offset = 13024
In catch at depth 9: stack frame count = 21, stack offset = 21888
In catch at depth 8: stack frame count = 19, stack offset = 30752
In catch at depth 7: stack frame count = 17, stack offset = 39616
In catch at depth 6: stack frame count = 15, stack offset = 48480
In catch at depth 5: stack frame count = 13, stack offset = 57344
In catch at depth 4: stack frame count = 11, stack offset = 66208
In catch at depth 3: stack frame count = 9, stack offset = 75072
In catch at depth 2: stack frame count = 7, stack offset = 83936
In catch at depth 1: stack frame count = 5, stack offset = 92800
In catch at depth 0: stack frame count = 3, stack offset = 101664
Finished with System.InvalidOperationException
抱歉。是的,在处理异常时堆栈实际上会增长。
当
_maxDepth = 1000
时,执行将在此结束:
In catch at depth 933: stack frame count = 1869, stack offset = 971232
In catch at depth 932: stack frame count = 1867, stack offset = 980096
In catch at depth 931: stack frame count = 1865, stack offset = 988960
In catch at depth 930: stack frame count = 1863, stack offset = 997824
Process is terminated due to StackOverflowException.
所以我们自己的代码使用了大约997824字节的堆栈空间,这非常接近Windows上默认的1 MB线程堆栈大小。调用CLR代码应该弥补差距。
正如您所知,SEH异常在两个阶段中处理:
- 第一阶段(过滤)找到能够处理该异常的第一个异常处理程序。在C#中,这基本上检查
catch
子句是否匹配正确的异常类型,并执行catch (...) when (...)
的when
部分(如果存在)。
- 第二阶段(展开)实际上处理异常。
以下是第二篇文章对展开过程的描述:
当发生异常时,系统遍历EXCEPTION_REGISTRATION
结构列表,直到找到异常处理程序。找到处理程序后,系统再次遍历列表,直到处理异常的节点。在第二次遍历期间,系统第二次调用每个处理程序函数。关键区别在于,在第二次调用中,异常标志设置为2。此值对应于EH_UNWINDING
。
[...]
在处理异常并调用所有先前的异常帧进行展开之后,执行继续到处理回调决定的位置。
这只是确认了第一篇文章所说的内容。
第一阶段需要保留故障堆栈以便能够检查其状态,并能够在故障指令上恢复执行(是的,这是一件事情-它非常低级,但您可以修补错误的原因并继续执行,就好像第一次没有错误一样)。
第二次遍历的实现方式与第一次相同,只是处理程序现在获得了
EH_UNWINDING
标志。这意味着在那一点上,故障堆栈仍然被保留,直到最终的处理程序决定在哪里恢复执行。
在堆栈展开时,32位进程的堆栈指针移动了36个字节,而64位进程的堆栈指针移动了惊人的8976个字节。这是为什么呢?
好问题!
这是因为32位和64位的SEH完全不同。这里有一些阅读材料(重点是我的):
由于在x86上,使用SEH的每个函数都将该结构作为其前奏部分的一部分,因此x86被称为基于帧的异常处理。这种方法存在几个问题:
- 由于异常信息存储在堆栈上,所以容易受到缓冲区溢出攻击的影响。
- 开销。异常是罕见的,这意味着异常不会发生在常见情况下。但无论如何,在每次输入使用SEH的函数时,都会执行这些额外的指令。
由于x64是摆脱了几十年的混乱,SEH得到了改进,解决了上述两个问题。在x64上,SEH已经变成了基于表的形式,这意味着在编译源代码时,将创建一个表,完全描述模块中所有异常处理代码。然后将此表存储为PE头的一部分。如果发生异常,则Windows会通过解析异常表来查找适当的异常处理程序进行执行。由于异常处理信息安全地藏在PE头中,因此不再容易受到缓冲区溢出攻击的影响。此外,由于异常表是作为编译过程的一部分生成的,因此正常处理过程中不会产生运行时开销(以push和pop指令的形式)。
当然,基于表的异常处理方案也有一些负面方面。例如,基于表的方案在内存中占用的空间更多。此外,虽然正常执行路径中的开销降低了,但处理异常需要的开销比基于帧的方法要高得多。像生活中的所有事情一样,在评估基于表还是基于帧的异常处理方法“最好”时,需要考虑权衡。
简而言之,x64优化了正常的路径,而异常路径变得更慢。如果你问我,这是一件非常好的事情。
以下是我之前链接的第一篇文章中的另一段引用:
“IA64和AMD64都有一个异常处理模型,避免了依赖于TLS中开始并沿着堆栈线程传递的显式处理程序链。相反,异常处理依赖于64位系统上我们可以完全展开堆栈的事实。这种能力本身是由于这些芯片所支持的调用约定非常受限制。”
“无论如何,在64位系统上,堆栈上的激活记录与适用于它的异常记录之间的对应关系不是通过FS:[0]链实现的。相反,堆栈的展开显示了对应于特定激活记录的代码地址。方法的这些指令指针在表中查找,以查找是否有任何__try / __except / __finally子句覆盖这些代码地址。该表还通过描述方法收场动作来指示如何进行展开。”
所以,是的。 完全不同的方法。
但让我们看一下x64调用堆栈,以查看堆栈空间实际上是如何使用的。 我将
Calculate
修改为以下内容:
private int Calculate(int depth)
{
try
{
if (depth >= _maxDepth)
throw new InvalidOperationException("Max. recursion depth reached.");
return Calculate2(depth + 1);
}
catch
{
if (depth == _maxDepth)
{
Console.ReadLine();
}
throw;
}
}
我在Console.ReadLine();
上设置了断点,并查看了每个帧的本地调用堆栈以及堆栈指针寄存器的值:
![native call stack](https://istack.dev59.com/R2zK0.webp)
这些地址fffffffffffffffe
和0000000000008000
看起来很奇怪,但无论如何,这显示了每个框架消耗的堆栈空间。在Windows本机API(ntdll.dll)中有很多东西正在进行,而CLR添加了一些。
就内部Windows内容而言,我们没有运气,因为源代码不公开。但是,我们至少可以查看clr.dll!ClrUnwindEx
,因为该函数相当小但使用了相当多的堆栈空间:
void ClrUnwindEx(EXCEPTION_RECORD* pExceptionRecord, UINT_PTR ReturnValue, UINT_PTR TargetIP, UINT_PTR TargetFrameSp)
{
PVOID TargetFrame = (PVOID)TargetFrameSp;
CONTEXT ctx;
RtlUnwindEx(TargetFrame,
(PVOID)TargetIP,
pExceptionRecord,
(PVOID)ReturnValue,
&ctx,
NULL);
UNREACHABLE();
}
它在栈上定义了一个名为CONTEXT
的变量,它是一个大结构体。我只能推测64位SEH函数使用它们的栈空间进行类似的操作。
现在让我们将其与32位调用栈进行比较:
![32-bit call stack](https://istack.dev59.com/nGoRU.webp)
作为您可以看到的,这与64位完全不同。
出于好奇心,我测试了一个简单的C++程序的行为:
#include "stdafx.h"
#include <iostream>
__declspec(noinline) static char* GetSomePointerNearTheTopOfTheStack()
{
char dummy;
return &dummy;
}
int main()
{
auto start = GetSomePointerNearTheTopOfTheStack();
try
{
throw 42;
}
catch (...)
{
auto here = GetSomePointerNearTheTopOfTheStack();
std::cout << "Difference in " << (sizeof(char*) * 8) << "-bit: " << (start - here) << std::endl;
}
return 0;
}
这里是结果:
Difference in 32-bit: 2224
Difference in 64-bit: 9744
这进一步表明这不仅仅是CLR的事情,而是由底层SEH实现差异导致的。
CalculateWithLazy
调用Calculate
,而Calculate
又调用CalculateWithLazy
。你有没有注意到这一点?这就是递归发生的无限循环,并导致堆栈溢出异常。 - Chetancatch { throw; }
在每一帧都重新抛出了这个异常(由于递归,你的try
块被嵌套:try { try { try { ...
)。因此,异常被捕获并重新抛出了 <最大递归深度> 次,这显然会消耗相当多的堆栈空间。 - Evk