为什么调用堆栈具有静态的最大大小?


46

使用过几种编程语言后,我一直想知道为什么线程堆栈具有预定义的最大大小,而不是根据需要自动扩展。 

相比之下,在大多数编程语言中发现的某些非常常见的高级结构(列表,地图等)被设计为在添加新元素时根据需要增长,其大小仅受可用内存或计算限制的限制(例如32位寻址)。

我不知道任何编程语言或运行时环境,其中最大堆栈大小不受某些默认或编译器选项的限制。这就是为什么太多递归会很快导致普遍存在的堆栈溢出错误/异常的原因,即使仅将进程可用内存的最小百分比用于堆栈。

为什么大多数(如果不是全部)运行时环境为堆栈在运行时可以增长的大小设置了最大限制?


13
这种堆栈是连续的地址空间,不能静默地移到幕后。地址空间在32位系统上很有价值。
CodesInChaos

7
为了减少象牙塔式想法的出现,例如递归从学术界泄漏出来,并在现实世界中引起问题,例如降低代码可读性和增加总体拥有成本;)
Brad Thomas

6
@BradThomas这就是尾调用优化的目的。
2016年

3
@JohnWu:现在做同样的事情,只是稍后:内存不足。
约尔格W¯¯米塔格

1
如果不是很明显,内存不足比堆栈耗尽更糟糕的原因之一是(假设有陷阱页)堆栈耗尽只会导致您的进程失败。内存用完可能会导致任何故障,然后下一个尝试进行内存分配的人将无法使用该内存。再有,在没有陷阱页或没有其他方法检测堆栈外的系统上,堆栈用尽可能是灾难性的,使您陷入不确定的行为。在这样的系统上,您宁愿用完免费存储的内存,而根本无法编写无限制递归的代码。
史蒂夫·杰索普

Answers:


13

可以编写一个不需要堆栈在地址空间中连续的操作系统。基本上,您需要在调用约定中添加一些其他信息,以确保:

  1. 如果当前堆栈范围中没有足够的空间用于调用的函数,那么您将创建一个新的堆栈范围,并在进行调用的过程中将堆栈指针移动到指向其开头的位置。

  2. 从该调用返回时,您将转移回原始堆栈范围。您很可能保留在(1)创建的那个供以后由同一线程使用。原则上可以释放它,但是那样的方式效率很低,在这种情况下,您会不断地在循环中来回跳越边界,并且每次调用都需要分配内存。

  3. setjmplongjmp,或您的操作系统用于非本地控制转移的任何等效功能,都可以使用,并且可以在需要时正确地移回旧堆栈范围。

我说的是“调用约定”-具体来说,我认为最好在函数序言中而不是由调用者完成,但是我对此记忆犹新。

相当多的语言为线程指定固定堆栈大小的原因是,它们希望在执行此操作的OS上使用本机堆栈工作。就像其他所有人的回答一样,在每个堆栈都必须在地址空间中连续且不能移动的假设下,您需要为每个线程保留一个特定的地址范围。这意味着要预先选择尺寸。即使您的地址空间很大并且您选择的大小确实很大,您仍然必须在拥有两个线程后立即选择它。

您说“啊哈”,“这些使用非连续堆栈的操作系统应该是什么?我敢打赌,这是一个对我来说毫无用处的晦涩的学术系统!”。好吧,这是另一个值得庆幸的问题


36

这些数据结构通常具有OS堆栈不具备的属性:

  • 链接列表不需要连续的地址空间。因此,他们可以在成长时随心所欲地添加一块内存。

  • 甚至需要连续存储的集合(如C ++的向量)也比OS堆栈具有优势:它们可以在增长时声明所有指针/迭代器为无效。另一方面,OS堆栈需要保持指向堆栈的指针有效,直到目标所属的函数返回为止。

编程语言或运行时可以选择实现自己的堆栈,这些堆栈可以是非连续的,也可以是可移动的,从而避免了OS堆栈的限制。Golang使用此类自定义堆栈来支持大量的协例程,这些协例程最初实现为非连续内存,现在由于指针跟踪而通过可移动堆栈实现(请参阅hobb的评论)。无堆栈的python,Lua和Erlang也可能使用自定义堆栈,但是我没有确认。

在64位系统上,您可以以相对较低的成本配置相对较大的堆栈,因为地址空间充足,并且仅在实际使用时才分配物理内存。


1
这是一个很好的答案,我遵循您的意思,但是术语“连续”而不是“连续”不是因为每个存储单元都有自己的唯一地址吗?
DanK 2013年

2
+1代表“调用栈不必受到限制”为了简化和提高性能,通常采用这种方式实现,但不必如此。
保罗·德雷珀

您对Go完全正确。实际上,我的理解是,旧版本具有不连续的堆栈,而新版本具有可移动的堆栈。无论哪种方式,都必须允许大量goroutine。每个goroutine为堆栈预分配几兆字节的空间会使它们太昂贵而无法正确实现其目的。
hobbs

@hobbs:是的,Go是从可增长的堆栈开始的,但是很难使其快速发展。当Go获得一个精确的垃圾收集器时,它背负着它来实现可移动的堆栈:当堆栈移动时,精确的类型映射将用于更新指向先前堆栈的指针。
Matthieu M.

26

在实践中,很难(有时甚至是不可能)增加堆栈。要了解原因,需要对虚拟内存有所了解。

在单线程应用程序和连续内存的时代中,三个是进程地址空间的三个组成部分:代码,堆和堆栈。这三种布局的方式取决于操作系统,但通常代码首先出现在内存的底部,然后是堆,然后向上增长,堆栈从内存的顶部开始,然后向下增长。还为操作系统保留了一些内存,但是我们可以忽略它。当时的程序在堆栈上的溢出更为剧烈:堆栈会崩溃到堆中,根据首先更新的内容,您要么处理错误的数据,要么从子例程返回内存的任意部分。

内存管理在某种程度上改变了这种模式:从程序的角度来看,您仍然拥有进程内存映射的三个组件,并且它们通常以相同的方式组织,但是现在每个组件都作为一个独立的段进行管理,并且MMU会发出信号操作系统,如果程序尝试访问段外部的内存。一旦有了虚拟内存,就不需要或不希望给程序访问其整个地址空间的权限。因此,这些段被分配了固定边界。

那么为什么不希望让程序访问其全部地址空间呢?因为该内存构成了对交换的“提交费用”;在任何时候,可能必须写入一个程序的任何或全部内存以进行交换,以便为另一个程序的内存腾出空间。如果每个程序都可能消耗2GB的交换空间,那么您要么必须为所有程序提供足够的交换空间,要么冒两个程序所需的交换能力。

此时,假设有足够的虚拟地址空间,则可以根据需要扩展这些段,并且数据段(堆)实际上会随着时间增长:您从一个小的数据段开始,并且当内存分配器请求更多空间时,这是必需的。在这一点上,使用单个堆栈,在物理上可以扩展堆栈段:操作系统可能会捕获将某些内容推入段之外并添加更多内存的尝试。但这也不是特别理想的。

输入多线程。在这种情况下,每个线程都有一个独立的堆栈段,该段也是固定大小。但是现在这些段在虚拟地址空间中一个接一个地布置,因此无法在不移动另一段的情况下扩展一个段-您无法执行此操作,因为程序可能会将指向内存的指针存储在堆栈中。您也可以在段之间留一些空间,但是几乎在所有情况下都将浪费该空间。更好的方法是让应用程序开发人员负担:如果您确实需要深层堆栈,则可以在创建线程时指定。

如今,有了64位虚拟地址空间,我们可以为无限数量的线程创建有效的无限堆栈。再次重申,这并不是特别理想:在几乎所有情况下,堆栈溢出都表明您的代码存在错误。为您提供1 GB的堆栈只会延缓该错误的发现。


3
当前的x86-64 CPU仅具有48位地址空间
CodesInChaos

Afaik,Linux 确实会动态增加堆栈:当进程尝试访问当前分配的堆栈正下方的区域时,仅通过映射堆栈的附加页面来处理中断,而不是对进程进行隔离。
cmaster

2
@cmaster:是的,但不是kdgregory所谓的“增长堆栈”。当前有一个地址范围指定用作堆栈。您所谈论的是根据需要逐步将更多的物理内存映射到该地址范围。kdgregory表示很难或不可能扩大范围。
史蒂夫·杰索普

x86并不是唯一的体系结构,并且48位实际上仍然是无限的
kdgregory

1
顺便说一句,我记得我在x86上度过的日子并不那么有趣,主要是因为需要处理分段。我更喜欢MC68k平台上的项目;-)
kdgregory

4

具有固定的最大尺寸的堆叠不是普遍存在的。

这也很难弄清楚:堆栈深度遵循幂律分布,这意味着无论堆栈大小变小,即使是较小的堆栈,仍有相当一部分功能(因此,您会浪费空间),并且无论您将它做成多大,仍然会有具有更大堆栈的函数(因此,对于没有错误的函数,您将强制产生堆栈溢出错误)。换句话说:无论您选择什么尺寸,它总是会同时变得太小和太大。

您可以通过允许堆栈从小开始并动态增长来解决第一个问题,但是第二个问题仍然存在。而且,如果您仍然允许堆栈动态增长,那么为什么要对其施加任意限制呢?

在某些系统中,堆栈可以动态增长并且没有最大大小:例如,Erlang,Go,Smalltalk和Scheme。有很多方法可以实现这样的功能:

  • 可移动堆栈:当连续堆栈由于有其他障碍而无法再增长时,请将其移动到内存中的另一个位置,并留出更多可用空间
  • 不连续的堆栈:不是在单个连续的内存空间中分配整个堆栈,而是在多个内存空间中分配它
  • 堆分配的堆栈:不必在堆栈和堆上有单独的内存区域,只需在堆上分配堆栈即可;如您所知,分配堆的数据结构通常不会出现按需增长和收缩的问题
  • 根本不要使用堆栈:这也是一种选择,例如,不要跟踪堆栈中的函数状态,而是让函数将延续传递给被调用者

一旦有了强大的非本地控制流构造,无论如何,单个连续堆栈的想法就会消失:例如,可恢复的异常和延续将“分叉”堆栈,因此实际上您最终得到了网络堆栈(例如,用意大利面条堆栈实现)。同样,具有一流可修改堆栈的系统(例如Smalltalk)几乎需要意大利面条堆栈或类似的东西。


1

当请求堆栈时,操作系统必须给出一个连续的块。唯一的方法是指定最大大小。

例如,假设在请求期间内存看起来像这样(X表示已使用,O未使用):

XOOOXOOXOOOOOX

如果要求堆栈大小为6,则即使有6个以上的堆栈,OS回答也将为否。如果要求堆栈大小为3,则操作系统答案将是连续3个空插槽(Os)的区域之一。

同样,可以看到当下一个连续的插槽被占用时允许增长的困难。

提到的其他对象(列表等)不会进入堆栈,它们最终会出现在非连续或零散的区域中的堆中,因此当它们增长时,它们只是抢占空间,因此不需要连续的空间管理方式不同。

大多数系统都会为堆栈大小设置一个合理的值,如果需要更大的大小,则可以在构造线程时覆盖它。


1

在linux上,这纯粹是一种资源限制,用于限制失控的进程在消耗有害资源量之前将其杀死。在我的debian系统上,以下代码

#include <sys/resource.h>
#include <stdio.h>

int main() {
    struct rlimit limits;
    getrlimit(RLIMIT_STACK, &limits);
    printf("   soft limit = 0x%016lx\n", limits.rlim_cur);
    printf("   hard limit = 0x%016lx\n", limits.rlim_max);
    printf("RLIM_INFINITY = 0x%016lx\n", RLIM_INFINITY);
}

产生输出

   soft limit = 0x0000000000800000
   hard limit = 0xffffffffffffffff
RLIM_INFINITY = 0xffffffffffffffff

请注意,硬限制设置为RLIM_INFINITY:允许进程将其软限制提高到任意数量。但是,只要程序员没有理由认为程序确实需要异常数量的堆栈内存,则当该进程超过8 MB的堆栈大小时,该进程将被终止。

由于这个限制,一个失控的进程(意外的无限递归)在开始消耗大量内存之前就被杀死了很长时间,以致系统被迫开始交换。这可以使崩溃的进程和崩溃的服务器有所不同。但是,它并不限制合法需要大堆栈的程序,它们只需要将软限制设置为适当的值即可。


从技术上讲,堆栈确实会动态增长:当soft-limit设置为8 MB时,这并不意味着实际上已映射了此内存量。这将是相当浪费的,因为大多数程序永远都无法接近其各自的软限制。相反,内核将检测堆栈下方的访问,并仅根据需要映射到内存页面中。因此,对堆栈大小的唯一真正限制是64位系统上的可用内存(理论上讲,地址空间的碎片化是16 zebibyte地址空间大小)。


2
那只是第一个线程的堆栈。新线程必须分配新堆栈,并且受到限制,因为它们会遇到其他对象。
Zan Lynx

0

最大堆栈大小是静态的,因为这是定义的“最大”。任何事物的任何最大值都是一个固定的,商定的极限值。如果它表现为自发移动的目标,则不是最大值。

实际上,虚拟内存操作系统上的堆栈确实会动态增长,直至达到最大值

说到这一点,它不必是静态的。相反,它甚至可以基于每个进程或每个线程进行配置。

如果问题是“为什么有一个最大堆栈大小”(人为强加的一个,通常比可用内存少了很多)?

原因之一是大多数算法不需要大量的堆栈空间。大量堆栈表示可能发生失控递归。最好在分配所有可用内存之前停止失控的递归。看起来失控递归的问题是退化的堆栈使用,可能是由意外的测试用例触发的。例如,假设用于二进制的infix运算符的解析器通过在正确的操作数上递归来工作:解析第一个操作数,scan运算符,解析表达式的其余部分。这意味着堆栈深度与表达式的长度成正比a op b op c op d ...。这种形式的巨大测试用例将需要巨大的堆栈。当程序达到合理的堆栈限制时中止该程序将捕获此错误。

固定最大堆栈大小的另一个原因是,可以通过特殊类型的映射为该堆栈保留虚拟空间,从而可以保证。保证意味着不会将空间分配给其他分配,堆栈会在达到极限之前与之冲突。为了请求此映射,需要最大堆栈大小参数。

出于类似的原因,线程需要最大堆栈大小。它们的堆栈是动态创建的,如果它们与某些物体碰撞,则无法移动;虚拟空间必须预先预留,并且该分配需要大小。


@Lynn没问为什么最大尺寸是静态的,他问为什么是预定义的。
Will Calderwood
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.