如何检测到StackOverflowException?

时间:2015-05-19 13:52:17

标签: c# stack-overflow infinite-loop

TL; TR
当我问这个问题时,我假设StackOverflowException是一个 防止应用程序无限运行的机制。事实并非如此 未检测到StackOverflowException 扔的时候 堆栈没有分配更多内存的能力。

[原始问题:]

这是一个普遍的问题,每种编程语言可能有不同的答案 我不确定C#以外的语言如何处理堆栈溢出。

我今天经历了异常,并一直在思考如何检测到StackOverflowException。我相信不可能说f.e.如果堆栈是1000深度调用,则抛出异常。因为在某些情况下,正确的逻辑可能会那么深。

在程序中检测无限循环的逻辑是什么?

StackOverflowException上课:
https://msdn.microsoft.com/de-de/library/system.stackoverflowexception%28v=vs.110%29.aspx

StackOverflowException类文档中提到的交叉引用:
https://msdn.microsoft.com/de-de/library/system.reflection.emit.opcodes.localloc(v=vs.110).aspx

我刚刚将stack-overflow标记添加到此问题中,并且描述说当调用堆栈消耗太多内存时它会被抛出。这是否意味着调用堆栈是我的程序当前执行位置的某种路径,如果它不能存储更多路径信息,那么抛出异常?

6 个答案:

答案 0 :(得分:42)

堆栈溢出

我会让你轻松;但这实际上非常复杂......请注意,我会在这里概括一下。

您可能知道,大多数语言都使用堆栈来存储呼叫信息。另请参阅:https://msdn.microsoft.com/en-us/library/zkwh89ks.aspx了解cdecl的工作原理。如果你调用一个方法,你可以在堆栈上推送东西;如果你回来,你就从堆栈中弹出东西。

请注意,递归通常不会内联'。 (注意:我明确地说这里是递归'这里没有'尾部递归&#39 ;;后者的工作方式类似于' goto'并且没有增长堆栈)

检测堆栈溢出的最简单方法是检查当前堆栈深度(例如使用的字节数) - 如果它遇到边界,则给出错误。澄清这个边界检查':这些检查的方式通常是使用保护页面;这意味着边界检查通常不会像if-then-else检查那样实现(尽管存在一些实现......)。

在大多数语言中,每个线程都有自己的堆栈。

检测无限循环

现在好了,这是一个我暂时没有听到的问题。 : - )

基本上检测所有无限循环需要您解决Halting Problem。这是undecidable problem的方式。这绝对不是由编译器完成的。

这并不意味着您无法进行任何分析;事实上,你可以做很多分析。但是,请注意,有时您希望无限期地运行(例如Web服务器中的主循环)。

其他语言

同样有趣......功能语言使用递归,因此它们基本上由堆栈绑定。 (也就是说,函数式语言也倾向于使用尾递归,这种函数或多或少类似于goto'并且不会增长堆栈。)

然后是那些逻辑语言..现在好了,我不确定如何永远循环 - 你可能会得到一些根本无法评估的东西(找不到解决方案)。 (虽然,这可能取决于语言......)

屈服,异步,延续

您可能会想到一个有趣的概念叫做continuations。我从微软那里听说,当yield首次实施时,真正的延续被认为是实施。延续基本上允许您“保存”#39;堆栈,继续在其他地方,并恢复'堆栈稍后回来......(同样,细节要比这复杂得多;这只是基本的想法)。

不幸的是,微软没有采用这个想法(虽然我可以想象为什么),但是通过使用帮助类来实现它。 C#中的yield和async通过添加临时类并实现类中的所有局部变量来实现。如果你打电话给一个“收益率”的方法。或者' async',你实际上是在堆上推送一个帮助类(来自你调用的方法并在堆栈上推送)。在堆上推送的类具有功能(例如,对于yield这是枚举实现)。这样做的方法是使用状态变量,该变量存储调用MoveNext时程序应该继续的位置(例如某个状态id)。使用此ID的分支(交换机)负责其余部分。请注意,这种机制没有做什么特别的'与堆栈的工作方式;你可以自己使用类和方法来实现它(它只涉及更多的输入: - ))。

使用手动堆栈解决堆栈溢出

我总是喜欢充满洪水。如果你做错了,一张图片会给你带来很多递归电话......比如说:

public void FloodFill(int x, int y, int color)
{
    // Wait for the crash to happen...
    if (Valid(x,y))
    {
        SetPixel(x, y, color);
        FloodFill(x - 1, y, color);
        FloodFill(x + 1, y, color);
        FloodFill(x, y - 1, color);
        FloodFill(x, y + 1, color);
    }
}

但这个代码没有错。它完成了所有的工作,但是我们的堆栈阻碍了它。手动堆栈解决了这个问题,即使实现基本相同:

public void FloodFill(int x, int y, int color)
{
    Stack<Tuple<int, int>> stack = new Stack<Tuple<int, int>>();
    stack.Push(new Tuple<int, int>(x, y));
    while (stack.Count > 0)
    {
        var current = stack.Pop();

        int x2 = current.Item1;
        int y2 = current.Item2;

        // "Recurse"
        if (Valid(x2, y2))
        {
            SetPixel(x2, y2, color);
            stack.Push(new Tuple<int, int>(x2-1, y2));
            stack.Push(new Tuple<int, int>(x2+1, y2));
            stack.Push(new Tuple<int, int>(x2, y2-1));
            stack.Push(new Tuple<int, int>(x2, y2+1));
        }
    }
}

答案 1 :(得分:33)

这里已经有很多答案,其中许多都得到了要点,其中许多都有微妙或大的错误。不要试图从头开始解释整个事情,而是让我达到一些高点。

  

我不确定C#以外的语言如何处理堆栈溢出。

您的问题是&#34;如何检测到堆栈溢出?&#34;您有关于如何在C#或其他语言中检测到它的问题吗?如果您对其他语言有疑问,建议您创建一个新问题。

  

我认为不可能说(例如)堆栈是否深度为1000次调用,然后抛出异常。因为在某些情况下,正确的逻辑可能会那么深。

完全可能来实现这样的堆栈溢出检测。在实践中,这不是如何完成的,但原则上没有理由说明为什么系统不能以这种方式设计。

  

在我的程序中检测无限循环的逻辑是什么?

你的意思是无界递归,而不是无限循环

我将在下面对其进行描述。

  

我刚刚在这个问题中添加了堆栈溢出标记,并且描述说当调用堆栈消耗太多内存时它会被抛出。这是否意味着调用堆栈是我的程序当前执行位置的某种路径,如果它不能存储更多路径信息,那么抛出异常?

简答:是的。

更长的答案:调用堆栈用于两个目的。

首先,代表激活信息。也就是说,局部变量和临时值的值,其寿命等于或短于方法的当前激活(&#34; call&#34;)。

其次,代表延续信息。也就是说,当我完成这个方法时,我接下来需要做什么?请注意,堆栈代表&#34;我来自哪里? &#34 ;.堆栈代表我下次去哪里,当一个方法返回时,通常是,你会回到你来自的地方。

堆栈还存储非本地延续的信息 - 即异常处理。当方法抛出时,调用堆栈包含的数据可帮助运行时确定哪些代码(如果有)包含相关的catch块。那个catch块然后成为 continuation - &#34;我接下来要做什么&#34; - 方法。

现在,在我继续之前,我注意到调用堆栈是一个用于两个目的的数据结构,违反了单一责任原则。没有要求有一个堆栈用于两个目的,实际上有一些奇特的架构,其中有两个堆栈,一个用于激活帧,一个用于返回地址(这是具体化)继续。)这种架构不易受到堆栈粉碎的影响。可能发生在C等语言中的攻击。<​​/ p>

当你调用一个方法时,在堆栈上分配内存来存储返回地址 - 接下来我该怎么做 - 以及激活帧 - 新方法的本地。 Windows上的堆栈默认为固定大小,因此如果没有足够的空间,就会发生不好的事情。

  

更详细地说,Windows如何进行堆栈检测?

我在20世纪90年代为32位Windows版本的VBScript和JScript编写了栈外检测逻辑; CLR使用与我使用的类似技术,但如果您想了解CLR特定的详细信息,您必须咨询CLR专家。

我们只考虑32位Windows; 64位Windows的工作方式类似。

Windows当然使用虚拟内存 - 如果您不了解虚拟内存的工作原理,那么现在是您在阅读之前学习的好时机。每个进程都有一个32位的扁平地址空间,一半为操作系统保留,一半用于用户代码。默认情况下,每个线程都有一个1兆字节地址空间的保留连续块。 (注意:这是线程重量级的一个原因。当你首先只有20亿字节时,一百万字节的连续内存很多。)

这里有一些细微之处,关于这个连续的地址空间是仅仅是保留还是实际提交,但让我们对它们进行掩饰。我将继续描述它在传统Windows程序中的工作原理,而不是进入CLR细节。

好的,所以我们可以说一百万字节的内存,分为250页,每页4kb。但是程序首次开始运行时只需要几kb的堆栈。所以这是它的工作原理。当前的堆栈页面是一个非常好的提交页面;它只是正常的记忆。 超越的页面被标记为保护页面。我们百万字节堆栈中的 last 页面被标记为一个非常特殊的保护页面。

假设我们尝试在堆栈页面之外编写一个堆栈内存字节。该页面受到保护,因此发生页面错误。操作系统通过使堆栈页面良好来处理故障, next 页面成为新的保护页面。

然而,如果 last 保护页面被击中 - 非常特殊 - 那么Windows会触发堆栈外异常,Windows会重置保护页面表示&#34;如果再次点击此页面,则终止该过程&#34;。如果发生这种情况,则Windows会立即终止进程。没有例外。没有清理代码。没有对话框。如果您曾经看到Windows应用程序突然完全消失,那么可能发生的事情是有人在第二次时间点击堆栈末尾的防护页面。

好了,现在我们了解了这些机制 - 再次,我在这里讨论了许多细节 - 你可能会看到如何编写产生堆栈外异常的代码。礼貌的方式 - 我在VBScript和JScript中所做的 - 是在堆栈上进行虚拟内存查询并询问最终防护页面的位置。然后定期查看当前的堆栈指针,如果它在几页内,只需创建一个VBScript错误或者抛出一个JavaScript异常,而不是让操作系统为你做。

如果您不想自己进行探测,那么您可以处理操作系统在最终防护页面被命中时给您的第一次机会异常,将其转换为C#理解的堆栈溢出异常,并且非常小心第二次没有点击防护页面。

答案 2 :(得分:13)

堆栈只是一个固定大小的内存块,在创建线程时分配。还有一个&#34;堆栈指针&#34;,一种跟踪当前正在使用多少堆栈的方法。作为创建新堆栈帧的一部分(在调用方法,属性,构造函数等时),它会将堆栈指针向上移动新帧所需的量。那时它将检查是否已经将堆栈指针移动到堆栈的末尾,如果是,则抛出一个SOE。

该程序无法检测无限递归。无限递归(当运行时被强制为每次调用创建一个新的堆栈帧时),它只会导致执行如此多的方法调用以填充此有限空间。您可以使用有限数量的嵌套方法调用轻松填充有限空间,这些方法调用恰好比堆栈消耗更多空间。 (这往往很难做到;它通常是由递归的方法引起的,而不是无限的,但是有足够的深度,堆栈无法处理它。)

答案 3 :(得分:6)

警告:这有一个很多与底层机制有关,包括CLR本身必须如何工作。如果你开始学习汇编级编程,这才有意义。

在幕后,通过将控制传递给另一个方法的站点来执行方法调用。为了传递参数和返回,它们被加载到堆栈中。为了知道如何控制返回到调用方法,CLR还必须实现一个调用堆栈,当调用一个方法并从一个方法时弹出方法返回。该堆栈告诉返回的方法 where 将控制权返回给。

由于计算机只有一个有限的内存,因此有时候调用堆栈会变得太大。因此,StackOverflowException 检测到无限运行或无限递归程序,检测到计算机无法再处理大小的堆栈需要跟踪您的方法需要返回的位置,必要的参数,返回,变量或(更常见地)它们的组合。在无限递归期间发生此异常的事实是因为逻辑不可避免地压倒了堆栈。

要回答您的问题,如果某个程序故意具有会使堆栈超载的逻辑,那么您将看到StackOverflowException。但是,除非您已经创建了无限递归循环,否则这通常是数千到百万的调用,并且很少是实际问题。

附录:我提到递归循环的原因是因为只有当你覆盖堆栈时才会发生异常 - 这通常意味着你正在调用最终回调到同一方法的方法,增加调用堆栈。如果你的某些东西在逻辑上是无限的,但递归,你通常不会看到StackOverflowException

答案 4 :(得分:4)

堆栈溢出的问题并不是它们可能源于无限计算。问题是堆栈内存耗尽,这是当今操作系统和语言中的有限资源。

当程序试图访问超出分配给堆栈的内存部分时,会检测到此情况。这导致例外。

答案 5 :(得分:3)

大多数子问题都得到了充分的回答。我想澄清有关检测堆栈溢出情况的部分,希望以比Eric Lippert的答案更容易理解的方式(当然,这是正确的,但是不必要的复杂。)相反,我会回答我的回答以不同的方式,提到不是一种,而是两种不同的方法。

有两种方法可以检测堆栈溢出:使用代码或借助硬件。

在PC以16位真实模式运行并且硬件笨拙的日子里,正在使用代码进行堆栈溢出检测。它不再使用,但值得一提。在这种情况下,我们指定一个编译器开关,要求编译器在我们编写的每个函数的开头发出一个特殊的隐藏的堆栈检查代码。这段代码只是读取堆栈指针寄存器的值,并检查它是否太靠近堆栈的末尾;如果是这样,它就会停止我们的计划。 x86架构上的堆栈向下增加,因此如果地址范围0x80000到0x90000已被指定为我们程序的堆栈,则堆栈指针最初指向0x90000,并且当您继续调用嵌套函数时,它会向下移向0x80000。因此,如果堆栈检查代码看到堆栈指针太接近0x80000(例如,等于或低于0x80010),那么它就会停止。

所有这一切的缺点是:a)为我们进行的每个单独的函数调用增加开销,以及b)在调用外部代码期间无法检测到堆栈溢出,而外部代码未使用该特殊编译器开关进行编译,因此没有执行任何堆栈溢出检查。在那些日子里,StackOverflow例外是一个闻所未闻的奢侈品:你的程序要么以非常简洁(一个几乎可以说是粗鲁)错误消息终止,要么就会出现系统崩溃,需要重新启动。

借助硬件进行堆栈溢出检测基本上将作业委托给CPU。现代CPU有一个精心设计的系统,用于将内存细分为页面(通常每个长4KB),并对每个页面执行各种技巧,包括在访问特定页面时自动发出中断(在某些架构中称为“陷阱”)的能力。因此,操作系统配置CPU的方式是,如果您尝试访问低于指定最小值的堆栈存储器地址,将发出中断。当发生中断时,它会被您的语言的运行时(在C#的情况下,.Net运行时)接收,并被转换为StackOverflow异常。

这样做的好处是绝对没有额外的开销。 CPU一直在执行的页面管理会产生开销,但无论如何都会得到报酬,因为虚拟内存需要工作,以及保护一个进程的内存地址空间等各种其他内容。流程等。