为什么堆栈溢出仍然存在问题?

时间:2010-07-10 01:57:51

标签: programming-languages stack memory-management

这个问题多年来一直让我感到困惑,考虑到这个网站的名字,这是值得一提的地方。

为什么我们程序员仍有这个StackOverflow问题?

为什么在每个主要语言中都必须在创建线程时静态分配线程堆栈内存?

我将在C#/ Java的上下文中发言,因为我最常使用它们,但这可能是一个更广泛的问题。

固定堆栈大小会导致巨大的问题:

  • 除非您绝对确定递归的深度很小,否则无法编写递归算法。递归算法的线性存储器复杂性通常是不可接受的。
  • 没有廉价的方法来启动新线程。您必须为堆栈分配大量内存来解释线程的所有可能用途。
  • 即使您不使用非常深的递归,由于堆栈大小是任意固定数,您总是有可能耗尽堆栈空间。考虑到StackOverflow通常是不可恢复的,这是我眼中的一个大问题。

现在,如果堆栈被动态地调整大小,所有的上述问题将大大缓解,因为堆栈溢出只会是可能时,有一个存储器溢出。

但事实并非如此。为什么?现代CPU有一些基本限制会使其变得不可能/效率低下吗?如果你想表现打的重新分配将实行,因为人们使用ArrayList所有的时间结构,而不吃苦应该是可以接受的。

所以,问题是,我失去了一些东西,在的StackOverflow 是不是有问题,还是我失去了一些东西,也有很多的动态栈的语言,或者是有一些大原因因为这是不可能/难以实施的?

修改 有些人说性能会是个大问题,但请考虑一下:

  • 我们保持编译后的代码不变。堆栈访问保持不变,因此“通常情况”性能保持不变。
  • 我们处理当代码尝试访问未分配的内存并启动我们的“重新分配”例程时发生的CPU异常。重新分配不会频繁,因为<把你通常的ArrayList参数放在这里>。应该在大多数保护模式CPU上工作而不会降低性能。否?

11 个答案:

答案 0 :(得分:21)

我从来没有遇到过无限递归造成的不是的堆栈溢出。在这些情况下,动态堆栈大小无济于事,只需要更长的时间就会耗尽内存。

答案 1 :(得分:20)

1)为了调整堆栈大小,您必须能够移动内存,这意味着堆栈调整大小后堆栈上任何内容的指针都会变为无效。是的,您可以使用其他级别的间接来解决此问题,但请记住,堆栈经常使用非常,非常

2)它使事情变得更加复杂。堆栈上的推/弹操作通常只需在CPU寄存器上执行一些指针算法即可。这就是为什么在堆栈上的分配比在免费商店上的分配更快的原因。

3)某些CPU(特别是微控制器)直接在硬件上实现堆栈,与主存储器分开。

另外,you can set the size of a stack of a thread when you create a new thread using beginthread(),所以如果你发现不需要额外的堆栈空间,你可以相应地设置堆栈大小。

根据我的经验,堆栈溢出通常是由无限递归或在堆栈上分配巨大数组的递归函数引起的。 According to MSDN, the default stack size set by the linker is 1MB (the header of executable files can set their own default),对于大多数情况而言似乎都足够大了。

固定堆栈机制适用于大多数应用程序,因此没有必要对其进行更改。如果没有,您可以随时推出自己的堆栈。

答案 2 :(得分:10)

我不能代表“主要语言”。许多“次要”语言执行堆分配的激活记录,每次调用使用一堆堆空间而不是线性堆栈块。这允许递归与您要分配的地址空间一样深。

这里的一些人声称深度错误的递归,并且使用“大线性堆栈”就好了。那是不对的。我同意,如果你必须使用整个地址空间,你会遇到某种问题。但是,当一个人拥有非常大的图形或树结构时,你想要允许深度递归,你不想先猜测你需要多少线性堆栈空间,因为你会猜错。

如果你决定并行,并且你有很多(数千到数百万的“谷物”[想想,小线程])你不能为每个线程分配10Mb的堆栈空间,因为你将浪费千兆字节RAM。你怎么会有一百万粒?容易:大量的谷物彼此互锁;当谷物被冻结等待锁定时,你无法摆脱它,但你仍然想要运行其他谷物来使用你的可用CPU。这最大化了可用工作量,因此可以有效地使用许多物理处理器。

PARLANSE并行编程语言使用这个非常大量的并行粒度模型,并在函数调用上使用堆分配。我们设计了PARLANSE来实现非常大的源计算机程序(例如,数百万行代码)的符号分析和转换。这些产生了......巨大的抽象语法树,巨型控制/数据流图,巨型符号表,拥有数千万个节点。并行工人有很多机会。

堆分配允许PARLANSE程序在词法范围内,甚至跨越并行边界,因为可以将“堆栈”实现为仙人掌堆栈,其中叉子出现在子堆的“堆栈”中,并且每个粒子因此可以看到其呼叫者的激活记录(父范围)。这使得在递归时传递大数据结构变得便宜;你只是在词汇上引用它们。

有人可能会认为堆分配会降低程序的速度。它确实; PARLANSE在性能上损失了大约5%的损失,但却能够并行处理非常大的结构,并且地址空间可以容纳多少颗粒。

答案 3 :(得分:5)

堆栈 动态调整大小 - 或者确切地说,动态增长。当堆栈无法进一步增长时会出现溢出,这并不是说它耗尽了地址空间,而是变得与用于其他目的的一部分内存冲突(例如,进程堆)。

也许你的意思是堆栈无法动态移动?其根源可能是堆栈与硬件紧密耦合。 CPU具有专用于线程堆栈管理的寄存器和成堆逻辑(尤其是ebp,x86上的调用/返回/进入/离开指令)。如果您的语言被编译(甚至是jitted),那么您将被绑定到硬件机制并且无法移动堆栈。

此硬件'限制'可能会留下来。在线程执行期间重新创建线程堆栈似乎远非硬件平台的合理需求(并且增加的复杂性将严重妨碍在这样的虚拟CPU上执行的所有代码,甚至编译)。人们可以想象一个完全虚拟化的环境,这种限制并不成立,但由于这样的代码无法进行攻击 - 这将是无法忍受的缓慢。你不可能做任何与之互动的事情。

答案 4 :(得分:4)

到目前为止,我将总结答案中的论点,因为我找不到足够好的答案。

静态堆栈调查

动机

不是每个人都需要它。

  • 大多数算法不使用深度递归或大量线程,因此很多人不需要动态堆栈。
  • 动态堆栈会使无限递归堆栈溢出,这是一个容易犯的错误,更难以诊断。 (内存溢出,虽然与当前进程的堆栈溢出一样致命,但对其他进程也是危险的)
  • 每个递归算法都可以使用类似的迭代算法进行模拟。

实施困难

动态堆栈实现结果并不像看起来那么简单。

  • 除非您拥有无限的地址空间,否则单独调整堆栈是不够的。您有时也需要重新定位堆栈。
  • 堆栈重定位需要更新指向堆栈上分配的数据结构的所有指针。虽然内存中的数据很简单(至少在托管语言中),但对于线程的CPU寄存器中的数据没有简单的方法。
  • 某些CPU(特别是微控制器)直接在硬件上实现堆栈,与主存储器分开。

现有实施

有些语言或运行时库已经具有动态堆栈功能或类似功能。

  • 某些运行时库(?)不预先提交为堆栈分配的整个内存块。这可以缓解这个问题,特别是对于64位系统,但不能完全消除它。
  • Ira Baxter told us关于PARLANSE,这是一种专门用于处理具有高度并行性的复杂数据结构的语言。它使用小堆分配的“颗粒”而不是堆栈。
  • fuzzy lolipop告诉我们“正确写了Erlang doesn't have stackoverflows!”
  • Google Go编程语言据说拥有动态堆栈。 (一个链接会很好)

我想在这里看到更多的例子。

我希望我没有忘记关于这个主题的任何重要信息。将其设为社区维基,以便任何人都可以添加新信息。

答案 5 :(得分:3)

  

为什么我们程序员仍然有这个StackOverflow问题?

固定大小的堆栈易于实现,99%的程序可以接受。 “堆栈溢出”是一个小问题,这有点罕见。因此没有真正的理由改变事物。此外,它不是语言问题,它与平台/处理器设计更相关,因此您将不得不处理它。

  

除非您绝对确定递归的深度很小,否则无法编写递归算法。递归算法的线性存储器复杂性通常是不可接受的。

现在这是不正确的。在递归算法中你可以(几乎?)总是用某种容器替换实际的递归调用 - 列表,std :: vector,堆栈,数组,FIFO队列等,它们将 act < / em>喜欢堆栈。计算将从容器的末尾“弹出”参数,并将新参数推送到容器的任一端或开头。通常情况下,此类容器大小的唯一限制是RAM总量。

这是一个粗略的C ++示例:

#include <deque>
#include <iostream>

size_t fac(size_t arg){
    std::deque<size_t> v;
    v.push_back(arg);
    while (v.back() > 2)
        v.push_back(v.back() - 1);
    size_t result = 1;
    for (size_t i = 0; i < v.size(); i++)
        result *= v[i];
    return result;
}

int main(int argc, char** argv){
    int arg = 12;
    std::cout << " fac of " << arg << " is " << fac(arg) << std::endl;
    return 0;
}

不如递归优雅,但没有stackoverflow问题。从技术上讲,我们在这种情况下“模仿”递归。您可以认为stackoverflow是您必须处理的硬件限制。

答案 6 :(得分:2)

我想我们会在几年内看到这种限制。

固定大小的堆栈根本没有基本的技术原因。它们存在是出于历史原因,并且因为编译器和VM的程序员是懒惰的,如果它现在足够好就不会优化。

但GO谷歌语言已经开始采用不同的方法。它以小4K片段分配堆栈。还有许多“无堆栈”编程语言扩展,如无堆栈python等,他们正在做同样的事情。

原因很简单,你拥有的线程越多,浪费的地址空间就越多。对于使用64位指针较慢的程序,这是一个严重的问题。在实践中,你真的不能拥有更多的亨德尔线程。如果你编写一个服务器可能想要为每个服务器提供一个线程服务器(在不久的将来等待100个核心/ CPU系统),那就不好了。

在64位系统上,它不是那么严重,但仍需要更多资源。例如,页面的TLB条目对于良好的性能非常严重。如果您可以使用一个TLB条目满足4000个正常线程堆栈(给定页面大小为16MB和4KB活动堆栈空间),您可以看到差异。不要为了几乎从不使用的堆栈而浪费1020KB。

小粒度多线程将来是一项非常重要的技术。

答案 7 :(得分:1)

在无限递归的情况下,具有几乎无限的堆栈空间将是非常糟糕的,因为它会将容易诊断的错误(堆栈溢出)变成更有问题的错误(内存不足)。在堆栈溢出的情况下,查看堆栈跟踪将很快告诉您发生了什么。或者,当系统内存不足时,它可能会尝试其他解决方法,例如使用交换空间,从而导致严重的性能下降。

另一方面,由于递归,我很少遇到触及堆栈溢出障碍的问题。但是,我可以想到发生这种情况的几种情况。但是,移动到我自己的堆栈实现为std :: vector是一个简单的问题解决方案。

现在,如果语言允许我将特定函数标记为“重度递归”,然后让它在自己的堆栈空间中运行,那么最好的是什么。这样我通常会在我的递归失败时获得停止的优势,但是当我想要时,我仍然可以使用广泛的递归。

答案 8 :(得分:0)

  
    

为什么在每个主要语言中都必须在创建线程时静态分配线程堆栈内存?

  

堆栈大小和分配不一定与您使用的语言相关。这更像是处理器和架构的问题。

在当前的英特尔处理器上,Stack Segments限制为4GB。

以下链接是一本很好的阅读材料,可以为您提供一些您想要的答案。

http://www.intel.com/Assets/PDF/manual/253665.pdf - 第6.2章

答案 9 :(得分:0)

旧语言实现具有静态堆栈大小,因此大多数新流行语言(只是复制旧语言,打破/修复了他们的感觉)都有同样的问题。

除非您处于正式的方法设置中,否则没有合理的理由拥有静态堆栈大小。为什么要在代码正确的地方引入故障?例如Erlang没有这样做,因为它处理错误,就像任何理智的部分编程语言一样。

答案 10 :(得分:-1)

任何会导致典型静态长度堆栈上的堆栈溢出的代码都是错误的。

  • 你可以使堆栈成为一个类似std :: vector的对象,但是当它决定调整大小时你会有极不可预测的性能 - 而且无论如何,它很可能会继续这样做,直到所有的堆都耗尽为止,这更令人讨厌。
  • 你可以把它变成一个std :: list,它在O(1)处增长。但是,在静态堆栈上使用的指针算法在编程性能的各个方面都非常关键,因此它将无用地慢。语言被发明为具有一个返回值和任意数量的输入参数,因为这适合静态堆栈/指针算术范例。

因此,动态可调整大小的堆栈将是A)性能噩梦和B)无论如何都没有价值,因为你的堆栈不应该那么深。