如何检测到StackOverflowException?

TL; TR
当我提出这个问题时,我认为StackOverflowException是一种防止应用程序无限运行的机制。 这不是真的。
StackOverflowException未被检测到。
当堆栈没有分配更多内存的能力时抛出。

[原始问题:]

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

我今天遇到exception,并不断思考如何检测到StackOverflowException 。 我相信这是不可能说,如果堆栈是1000电话深,然后抛出exception。 因为也许在某些情况下,正确的逻辑将是那么深。

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

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标签到这个问题,说明说,当调用堆栈消耗太多的内存时,它正在被抛出。 这是否意味着调用堆栈是我的程序的当前执行位置的某种path,如果它不能存储更多的path信息 ,那么抛出exception?

堆栈溢出

我会让你轻松的 但这实际上相当复杂…请注意,我会在这里概括一下。

大家可能知道,大多数语言都使用栈来存储通话信息。 另请参阅: https ://msdn.microsoft.com/en-us/library/zkwh89ks.aspx关于cdecl如何工作。 如果你调用一个方法,你就把东西放在堆栈上; 如果你回来,你从堆栈中popup东西。

请注意,recursion通常不是“内联”的。 (注意:我在这里明确地说'recursion',而不是'尾recursion';后者像“goto”一样工作,不会增长堆栈)。

检测堆栈溢出的最简单方法是检查当前的堆栈深度(例如使用的字节),如果遇到边界,则报错。 为了澄清这个“边界检查”:这些检查的方式通常是使用警卫页面; 这意味着边界检查通常不被执行为if-then-else检查(尽pipe存在一些实现…)。

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

检测无限循环

那么现在呢,这个问题我还没有听说过。 🙂

基本上检测所有无限循环需要你解决停机问题 。 这是一个不可解决的问题 。 这绝对不是由编译器完成的。

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

其他语言

也有趣…function语言使用recursion,所以他们基本上是由堆栈绑定。 (也就是说,函数式语言也倾向于使用尾recursion,它或多或less地像'goto'一样工作,不会增长堆栈。)

然后是逻辑语言..现在,我不知道如何永远循环 – 你可能会得到一些根本不会评估的东西(没有解决scheme可以find)。 (虽然这可能取决于语言…)

屈服,asynchronous,延续

一个有趣的概念是你可能会想到的叫做continuations 。 我从微软那里听说,当第一次实现yield时,真正的延续被认为是实现。 继续基本上允许你“保存”堆栈,在其他地方继续,并在稍后的时间“恢复”堆栈…(再一次,细节比这更复杂;这只是基本的想法)。

不幸的是,微软并没有去做这个想法(虽然我能想象为什么),但是通过使用一个辅助类来实现它。 C#中的产出和asynchronous工作是通过添加一个临时类并在类中实现所有局部variables。 如果你调用了一个“yield”或者“async”的方法,你实际上会创build一个帮助器类(从你调用的方法中推入堆栈),这个类被推入堆中。 在堆上推送的类具有function(例如,对于yield这是枚举实现)。 这样做的方式是通过使用一个状态variables来存储位置(例如某个状态ID),当MoveNext被调用时程序应该继续MoveNext 。 使用这个ID的一个分支(交换机)负责其余部分。 请注意,这种机制与堆栈的工作方式没有什么特别的关系。 你可以使用类和方法自己实现它(它只是涉及更多的input:-))。

用手动堆栈解决堆栈溢出问题

我总是喜欢一个好的洪水填充。 如果你这样做的错误,一张图片会给你一个很多的recursion调用的地狱…说,像这样:

 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)); } } } 

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

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

你的问题是“如何检测到堆栈溢出?” 您的问题是关于如何在C#或其他语言中检测到的? 如果您对另一种语言有疑问,我build议您创build一个新的问题。

我认为这是不可能的(例如)如果堆栈是1000电话深,然后抛出exception。 因为也许在某些情况下,正确的逻辑将是那么深。

像这样实现堆栈溢出检测是完全可能的 。 在实践中,这不是如何完成的,但没有原则的原因,为什么系统不能这样devise。

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

你的意思是一个无限的recursion ,而不是一个无限循环

我将在下面描述。

我刚刚添加了堆栈溢出标签到这个问题,说明说,当调用堆栈消耗太多的内存时,它正在被抛出。 这是否意味着调用堆栈是我的程序的当前执行位置的某种path,如果它不能存储更多的path信息,那么抛出exception?

简短的回答:是的。

较长的回答:调用堆栈有两个用途。

首先,表示激活信息 。 也就是说,局部variables和临时值的值等于或短于方法的当前激活(“调用”)。

其次,代表延续信息 。 也就是说, 当我完成这个方法的时候,接下来我需要做什么? 请注意,堆栈并不代表“我从哪里来?”。 堆栈表示下一步我要去哪里通常当方法返回时,你会回到你来自哪里。

堆栈还存储非本地继续的信息 – 即exception处理。 当一个方法抛出时,调用堆栈包含的数据可以帮助运行时确定哪些代码(如果有的话)包含相关的catch块。 那个catch块就成为这个方法的延续 – “接下来我要做什么”。

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

当你调用一个方法的时候,内存被分配到堆栈上来存储返回地址 – 接下来我要做什么 – 以及激活框架 – 新方法的本地化。 Windows上的堆栈默认是固定的大小,所以如果没有足够的空间,会发生不好的事情。

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

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

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

当然,Windows使用虚拟内存 – 如果你不明白虚拟内存是如何工作的,在阅读之前现在应该是学习的好时机。 每个进程都有一个32位的平面地址空间,一半用于操作系统,一半用于用户代码。 默认情况下,每个线程都被赋予一个1兆字节地址空间的保留连续块。 (注意:这是线程重量级的一个原因,当你只有二十亿字节时,一百万字节的连续内存是很多的。)

这里有一些微妙的地方是关于这个连续的地址空间是仅仅是保留的还是实际承诺的,但是让我们把它们加以光泽。 我将继续描述它如何在传统的Windows程序中工作,而不是进入CLR细节。

好吧,我们可以说一百万字节的内存,分成250页,每页4KB。 但是程序刚开始运行时,只需要几个字节的堆栈。 所以这是它的工作原理。 当前的堆栈页面是一个完美的提交页面; 这只是正常的记忆。 超出该页面的页面被标记为警卫页面。 我们的百万字节堆栈中的最后一页被标记为非常特殊的警戒页面。

假设我们试图在堆栈页面之外写入一个堆栈内存字节。 该页面被保护,所以发生页面错误。 操作系统通过使该堆栈页面良好来处理该故障,并且下一页成为新的防护页面。

但是 ,如果最后一个守护页被击中 – 非常特殊的 – 那么Windows会触发堆栈外的exception,并且Windows将防护页重置为“如果这个页面再次被击中,则终止该过程”。 如果发生这种情况,Windows将立即终止该进程。 没有例外。 没有清理代码。 没有对话框。 如果你曾经见过一个Windows应用程序突然完全消失,可能发生了什么事情是有人第二次在堆栈的末尾打了一个警卫页面。

好的,现在我们已经理解了这些机制 – 再次,我在这里详细描述了许多细节 – 您可能会看到如何编写出现堆栈外例外的代码。 有礼貌的方式 – 就是我在VBScript和JScript中所做的 – 就是在堆栈上进行虚拟内存查询,并询问最终的守卫页面的位置。 然后定期查看当前的堆栈指针,如果在几页之内,只需创build一个VBScript错误或抛出一个JavaScriptexception,而不是让操作系统为您做。

如果你不想这样做,那么你可以处理操作系统给你的第一个机会exception,当最后的守护页被击中时,把它变成C#可以理解的堆栈溢出exception,并且非常小心没有第二次打到守卫页面。

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

程序不会检测到无限recursion。 无限recursion(当运行时被迫为每个调用创build一个新的栈帧时),它只会导致执行太多的方法调用来填充这个有限的空间。 您可以使用有限数量的嵌套方法调用来轻松地填充有限空间,这些调用恰好消耗了比堆栈更多的空间。 (但是这往往是相当困难的,但通常是由recursion的方法引起的,而不是无限的,但是深度足够深,栈不能处理它。

警告:这与引擎罩机制下的很多事情有关,包括CLR本身如何工作。 如果你开始研究汇编级编程,这才真正有意义。

在引擎盖下,方法调用通过将控制权传递给另一个方法的站点来执行。 为了传递参数和返回值,这些被加载到堆栈上。 为了知道如何将控制权返回给调用方法,CLR还必须实现一个调用堆栈调用堆栈将在方法返回时调用并从中popup。 这个栈告诉返回的方法返回到哪里

由于计算机只有有限的内存,有时候调用堆栈变得太大。 因此, StackOverflowException 不是检测无限运行或无限recursion的程序 ,而是检测到计算机不能处理需要跟踪方法需要返回的位置所需的堆栈大小,必要的参数,返回,variables或(更普通)其组合。 这个exception发生在无限recursion期间的事实是因为逻辑不可避免地压倒了堆栈。

要回答你的问题,如果一个程序故意有逻辑,会重载堆栈然后是的,你会看到一个StackOverflowException 。 然而,除非你创build了一个无限recursion循环,否则这通常是数千甚至数百万次深度的调用,而且几乎不是真正的问题。

附录:我提到recursion循环的原因是因为exception只会在堆栈中产生时才会发生 – 这意味着你调用的方法最终会调用相同的方法并调用堆栈。 如果你有什么是逻辑上无限的,但不是recursion的,你通常不会看到一个StackOverflowException

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

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

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

有两种检测堆栈溢出的方法:使用代码或者在硬件的帮助下。

使用代码的堆栈溢出检测在PC以16位真实模式运行并且硬件性能渺茫的时代已经被使用。 它不再使用,但值得一提。 在这种情况下,我们指定一个编译器开关,要求编译器在我们编写的每个函数的开头发出一个特殊的隐藏的栈检查代码。 该代码只读取堆栈指针寄存器的值,并检查它是否太靠近堆栈的末尾; 如果是的话,它会暂停我们的计划。 x86体系结构上的堆栈向下增加,所以如果地址范围0x80000到0x90000被指定为我们的程序堆栈,那么堆栈指针最初指向0x90000,并且随着您不断调用嵌套函数,它将朝向0x80000。 所以,如果堆栈检查代码看到堆栈指针太接近0x80000,(即,在0x80010或以下),那么它就停止。

所有这些缺点是:a)为我们所做的每一个函数调用增加开销,以及b)在调用外部代码时没有能够检测到堆栈溢出,而这些外部代码并没有用那个特殊的编译器开关编译,因此没有执行堆栈溢出检查。 在那些日子里,一个StackOverflow例外是一个豪华的闻所未闻的:你的程序要么终止一个非常简洁的(一个几乎可以说粗鲁 )的错误信息,否则将有一个系统崩溃,需要重新启动。

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

这有绝对没有额外的开销的好处。 与CPU一直在执行的页面pipe理有关的开销,但是无论如何,这是虚拟内存所必需的,还有其他一些其他的东西,比如保护一个进程的内存地址空间进程等