Warum hat der Aufrufstapel eine statische Maximalgröße?


46

Nachdem ich mit einigen Programmiersprachen gearbeitet habe, habe ich mich immer gefragt, warum der Thread-Stapel eine vordefinierte maximale Größe hat, anstatt sich automatisch nach Bedarf zu erweitern. 

Im Vergleich dazu sind bestimmte, in den meisten Programmiersprachen häufig vorkommende Strukturen (Listen, Maps usw.) so konzipiert, dass sie nach Bedarf wachsen, während neue Elemente hinzugefügt werden. Ihre Größe wird nur durch den verfügbaren Speicher oder durch Recheneinschränkungen begrenzt ( zB 32 Bit Adressierung).

Mir sind jedoch keine Programmiersprachen oder Laufzeitumgebungen bekannt, in denen die maximale Stapelgröße nicht durch eine Standard- oder Compileroption begrenzt ist. Aus diesem Grund führt zu viel Rekursion sehr schnell zu einem allgegenwärtigen Stapelüberlauffehler / -ausnahme, selbst wenn nur ein minimaler Prozentsatz des für einen Prozess verfügbaren Speichers für den Stapel verwendet wird.

Warum legen die meisten (wenn nicht alle) Laufzeitumgebungen eine maximale Grenze für die Größe fest, die ein Stack zur Laufzeit erreichen kann?


13
Diese Art von Stapel ist ein fortlaufender Adressraum, der nicht unbemerkt hinter die Kulissen verschoben werden kann. Der Adressraum ist auf 32-Bit-Systemen von großem Wert.
CodesInChaos

7
Um das Auftreten von Elfenbeinturm-Ideen wie Rekursion, die aus der Wissenschaft austritt und Probleme in der realen Welt verursacht, wie verringerte Codelesbarkeit und erhöhte Gesamtbetriebskosten, zu reduzieren;)
Brad Thomas,

6
@BradThomas Dafür ist die Tail Call-Optimierung gedacht.
JAB

3
@ JohnWu: Das gleiche, was es jetzt, nur ein wenig später, tut: Der Speicher geht zur Neige.
Jörg W Mittag

1
Falls dies nicht offensichtlich ist, ist ein Grund dafür, dass der Arbeitsspeicher knapp wird, schlimmer als der Stapel. Dies liegt daran, dass (vorausgesetzt, es gibt eine Trap-Seite) der Stapel knapp wird und Ihr Prozess nur fehlschlägt. Zu wenig Arbeitsspeicher kann dazu führen, dass alles fehlschlägt, unabhängig davon, wer als Nächstes versucht, eine Speicherzuweisung vorzunehmen. Andererseits kann es auf einem System ohne Trap-Seite oder andere Mittel zum Erkennen von Stapelverknappung katastrophal sein, wenn Stapelverknappung auftritt und Sie in ein undefiniertes Verhalten verwickelt werden. Auf einem solchen System würde Ihnen der freie Speicherplatz viel lieber ausgehen, und Sie können einfach keinen Code mit unbegrenzter Rekursion schreiben.
Steve Jessop

Antworten:


13

Es ist möglich, ein Betriebssystem zu schreiben, bei dem Stapel im Adressraum nicht zusammenhängend sein müssen. Grundsätzlich müssen Sie in der Aufrufkonvention ein bisschen herumspielen, um sicherzustellen, dass:

  1. Wenn im aktuellen Stack-Bereich nicht genügend Speicherplatz für die aufgerufene Funktion vorhanden ist, erstellen Sie einen neuen Stack-Bereich und bewegen den Stack-Zeiger, um im Rahmen des Aufrufs auf den Anfang zu zeigen.

  2. Wenn Sie von diesem Aufruf zurückkehren, kehren Sie zum ursprünglichen Stapelbereich zurück. Höchstwahrscheinlich behalten Sie die unter (1) erstellte Datei für die zukünftige Verwendung durch denselben Thread bei. Im Prinzip könnten Sie es freigeben, aber auf diese Weise liegen eher ineffiziente Fälle vor, in denen Sie in einer Schleife immer wieder über die Grenze hüpfen und für jeden Anruf eine Speicherzuweisung erforderlich ist.

  3. setjmpund longjmp, oder was auch immer Ihr Betriebssystem für die nicht-lokale Übertragung der Kontrolle gleichwertig ist, sind auf dem Laufenden und können bei Bedarf korrekt zum alten Stack-Umfang zurückkehren.

Ich sage "Aufrufkonvention" - um genau zu sein, ich denke, es wird wahrscheinlich am besten in einem Funktionsprolog und nicht vom Aufrufer durchgeführt, aber meine Erinnerung daran ist verschwommen.

Der Grund, warum nicht wenige Sprachen eine feste Stapelgröße für einen Thread festlegen, ist, dass sie unter Betriebssystemen, die dies nicht tun, mit dem nativen Stapel arbeiten möchten . In den Antworten aller anderen heißt es, dass unter der Annahme, dass jeder Stapel im Adressraum zusammenhängend sein muss und nicht verschoben werden kann, für jeden Thread ein bestimmter Adressbereich reserviert werden muss. Das bedeutet, dass Sie im Voraus eine Größe auswählen müssen. Auch wenn Ihr Adressraum sehr groß und die von Ihnen gewählte Größe sehr groß ist, müssen Sie ihn auswählen, sobald Sie zwei Threads haben.

"Aha", sagen Sie, "was sind diese vermeintlichen Betriebssysteme, die nicht zusammenhängende Stapel verwenden? Ich wette, es ist ein obskures akademisches System, das für mich keinen Nutzen hat!" Nun, das ist eine weitere Frage , die zum Glück bereits gestellt und beantwortet wurde.


36

Diese Datenstrukturen haben normalerweise Eigenschaften, die der Betriebssystemstapel nicht hat:

  • Verknüpfte Listen erfordern keinen zusammenhängenden Adressraum. So können sie ein Stück Gedächtnis hinzufügen, von wo immer sie wollen, wenn sie wachsen.

  • Sogar Sammlungen, die zusammenhängenden Speicher benötigen, wie der Vektor von C ++, haben einen Vorteil gegenüber Betriebssystemstapeln: Sie können alle Zeiger / Iteratoren für ungültig erklären, wenn sie größer werden. Andererseits muss der Betriebssystemstapel Zeiger auf den Stapel gültig halten, bis die Funktion, zu deren Frame das Ziel gehört, zurückkehrt.

Eine Programmiersprache oder Laufzeitumgebung kann eigene Stapel implementieren, die entweder nicht zusammenhängend oder verschiebbar sind, um die Einschränkungen der Betriebssystemstapel zu umgehen. Golang verwendet solche benutzerdefinierten Stapel, um sehr viele Co-Routinen zu unterstützen, die ursprünglich als nicht zusammenhängender Speicher implementiert waren und jetzt dank Zeigerverfolgung über bewegliche Stapel (siehe hobbs Kommentar). Stackless Python, Lua und Erlang verwenden möglicherweise auch benutzerdefinierte Stacks, aber das habe ich nicht bestätigt.

Auf 64-Bit-Systemen können Sie relativ große Stapel mit relativ geringen Kosten konfigurieren, da der Adressraum sehr groß ist und der physische Speicher nur zugewiesen wird, wenn Sie ihn tatsächlich verwenden.


1
Dies ist eine gute Antwort und ich folge Ihrer Bedeutung, aber ist der Begriff nicht ein "zusammenhängender" Speicherblock im Gegensatz zu "fortlaufend", da jede Speichereinheit ihre eigene eindeutige Adresse hat?
DanK

2
+1 für "ein Aufrufstapel muss nicht begrenzt sein" Oft wird er aus Gründen der Einfachheit und Leistung auf diese Weise implementiert, muss es aber nicht sein.
Paul Draper

Sie haben völlig recht mit Go. Nach meinem Verständnis hatten die alten Versionen nicht zusammenhängende Stapel und die neuen Versionen bewegliche Stapel. In jedem Fall ist es notwendig, eine große Anzahl von Goroutinen zuzulassen. Wenn Sie ein paar Megabyte pro Goroutine für den Stack vorbelegen, sind sie zu teuer, um ihren Zweck ordnungsgemäß zu erfüllen.
Hobbs

@hobbs: Ja, Go begann mit anbaubaren Stacks, aber es war schwierig, sie schnell zu machen. Wenn Go einen präzisen Garbage Collector erhalten hat, wurde er huckepack verwendet, um bewegliche Stapel zu implementieren: Wenn sich der Stapel bewegt, werden die Zeiger mit der präzisen Typzuordnung auf den vorherigen Stapel aktualisiert.
Matthieu M.

26

In der Praxis ist es schwierig (und manchmal unmöglich), den Stapel zu vergrößern. Um zu verstehen, warum dies erforderlich ist, müssen Sie den virtuellen Speicher verstehen.

In Ye Olde Days mit Single-Thread-Anwendungen und zusammenhängendem Speicher waren drei Komponenten eines Prozessadressraums: der Code, der Heap und der Stapel. Wie diese drei angeordnet waren, hing vom Betriebssystem ab, aber im Allgemeinen stand der Code an erster Stelle, beginnend am unteren Ende des Speichers, der Heap kam als nächstes und wuchs nach oben, und der Stack begann am oberen Ende des Speichers und wuchs nach unten. Es war auch etwas Speicher für das Betriebssystem reserviert, aber das können wir ignorieren. Programme hatten damals etwas dramatischere Stapelüberläufe: Der Stapel stürzte in den Heap, und je nachdem, was zuerst aktualisiert wurde, würden Sie entweder mit schlechten Daten arbeiten oder von einer Subroutine in einen beliebigen Teil des Speichers zurückkehren.

Die Speicherverwaltung hat dieses Modell ein wenig verändert: Aus Sicht des Programms hatten Sie noch die drei Komponenten einer Prozessspeicherzuordnung und sie waren im Allgemeinen auf die gleiche Weise organisiert, aber jetzt wurde jede der Komponenten als unabhängiges Segment verwaltet, und die MMU signalisierte dies Betriebssystem, wenn das Programm versucht hat, auf Speicher außerhalb eines Segments zuzugreifen. Sobald Sie über virtuellen Speicher verfügten, bestand keine Notwendigkeit oder kein Wunsch mehr, einem Programm Zugriff auf den gesamten Adressraum zu gewähren. So wurden den Segmenten feste Grenzen zugewiesen.

Warum ist es also nicht wünschenswert, einem Programm Zugriff auf seinen gesamten Adressraum zu gewähren? Weil diese Erinnerung eine "Festschreibegebühr" gegen den Swap darstellt; Zu jedem Zeitpunkt muss möglicherweise der gesamte oder ein Teil des Speichers für ein Programm geschrieben werden, um Platz für den Speicher eines anderen Programms zu schaffen. Wenn jedes Programm möglicherweise 2 GB Swap verbrauchen könnte, müssten Sie entweder genug Swap für alle Ihre Programme bereitstellen oder die Chance nutzen, dass zwei Programme mehr benötigen, als sie bekommen könnten.

Unter der Annahme eines ausreichenden virtuellen Adressraums könnten Sie diese Segmente bei Bedarf erweitern, und das Datensegment (Heap) wächst tatsächlich mit der Zeit: Sie beginnen mit einem kleinen Datensegment, und wenn der Speicherzuordner wann mehr Speicherplatz anfordert es ist nötig. Zu diesem Zeitpunkt wäre es mit einem einzelnen Stapel physikalisch möglich gewesen, das Stapelsegment zu erweitern: Das Betriebssystem könnte den Versuch abfangen, etwas außerhalb des Segments zu verschieben und mehr Speicher hinzuzufügen. Dies ist aber auch nicht besonders wünschenswert.

Geben Sie Multithreading ein. In diesem Fall hat jeder Thread ein unabhängiges Stapelsegment, wiederum mit fester Größe. Aber jetzt werden die Segmente nacheinander im virtuellen Adressraum angeordnet, sodass es keine Möglichkeit gibt, ein Segment zu erweitern, ohne ein anderes zu verschieben. Dies ist nicht möglich, da das Programm möglicherweise Zeiger auf den im Stapel befindlichen Speicher enthält. Sie könnten alternativ etwas Platz zwischen den Segmenten lassen, aber dieser Platz würde in fast allen Fällen verschwendet. Ein besserer Ansatz bestand darin, den Anwendungsentwickler zu entlasten: Wenn Sie wirklich tiefe Stapel benötigen, können Sie dies beim Erstellen des Threads angeben.

Mit einem virtuellen 64-Bit-Adressraum könnten wir heute praktisch unendlich viele Stapel für praktisch unendlich viele Threads erstellen. Aber auch das ist nicht besonders wünschenswert: In fast allen Fällen weist ein Stapelüberlauf auf einen Fehler in Ihrem Code hin. Wenn Sie einen 1-GB-Stack bereitstellen, wird die Entdeckung dieses Fehlers lediglich verzögert.


3
Gegenwärtige x86-64-CPUs haben nur 48 Bit Adressraum
CodesInChaos

Unter Linux wird der Stapel dynamisch vergrößert: Wenn ein Prozess versucht, auf den Bereich direkt unterhalb des aktuell zugewiesenen Stapels zuzugreifen, wird der Interrupt behandelt, indem lediglich eine zusätzliche Seite des Stapelspeichers zugeordnet wird, anstatt den Prozess zu beschädigen.
cmaster

2
@cmaster: wahr, aber nicht was kdgregory mit "grow the stack" meint. Derzeit ist ein Adressbereich für die Verwendung als Stapel festgelegt. Sie sprechen davon, diesem Adressbereich nach und nach mehr physischen Speicher zuzuordnen, als erforderlich ist. kdgregory sagt, es sei schwierig oder unmöglich, die Reichweite zu erhöhen.
Steve Jessop

x86 ist nicht die einzige Architektur, und 48 Bit sind immer noch
praktisch

1
Übrigens, ich erinnere mich, dass meine Tage mit dem x86 nicht so viel Spaß gemacht haben, vor allem, weil ich mich mit Segmentierung befassen musste. Ich habe Projekte auf MC68k-Plattformen sehr bevorzugt ;-)
kdgregory

4

Der Stapel mit einer festen Maximalgröße ist nicht allgegenwärtig.

Es ist auch schwierig, es richtig zu machen: Die Stapeltiefen folgen einer Potenzgesetzverteilung, was bedeutet, dass unabhängig von der Größe des Stapels immer noch ein erheblicher Teil der Funktionen mit noch kleineren Stapeln vorhanden ist (Sie verschwenden also Platz). und egal wie groß Sie es machen, es wird immer noch Funktionen mit noch größeren Stapeln geben (so erzwingen Sie einen Stapelüberlauffehler für Funktionen, die keinen Fehler haben). Mit anderen Worten: Welche Größe Sie auch wählen, es ist immer gleichzeitig zu klein und zu groß.

Sie können das erste Problem beheben, indem Sie zulassen, dass Stapel klein anfangen und dynamisch wachsen, aber dann haben Sie immer noch das zweite Problem. Und wenn Sie dem Stack trotzdem erlauben, dynamisch zu wachsen, warum sollten Sie dann eine willkürliche Grenze setzen?

Es gibt Systeme, in denen Stapel dynamisch wachsen können und keine maximale Größe haben: Erlang, Go, Smalltalk und Scheme. Es gibt viele Möglichkeiten, so etwas zu implementieren:

  • Bewegliche Stapel: Wenn der zusammenhängende Stapel nicht mehr wachsen kann, weil etwas anderes im Weg ist, verschieben Sie ihn an einen anderen Speicherort mit mehr freiem Speicherplatz
  • diskontinuierliche Stapel: Anstatt den gesamten Stapel in einem einzigen zusammenhängenden Speicherbereich zuzuordnen, ordnen Sie ihn mehreren Speicherbereichen zu
  • Heap-zugewiesene Stapel: Anstatt separate Speicherbereiche für Stapel und Heap zu haben, ordnen Sie einfach den Stapel auf dem Heap zu. Wie Sie selbst bemerkt haben, können Datenstrukturen mit Heap-Zuweisung bei Bedarf problemlos wachsen und schrumpfen
  • Verwenden Sie überhaupt keine Stapel. Dies ist auch eine Option. Lassen Sie die Funktion beispielsweise eine Fortsetzung an den Angerufenen übergeben, anstatt den Funktionsstatus in einem Stapel zu verfolgen

Sobald Sie über leistungsfähige nicht-lokale Kontrollflusskonstrukte verfügen, geht die Idee eines einzelnen zusammenhängenden Stapels ohnehin aus dem Fenster: Wiederaufsetzbare Ausnahmen und Fortsetzungen beispielsweise "spalten" den Stapel auf, sodass Sie tatsächlich ein Netzwerk haben Stapel (z. B. mit einem Spaghetti-Stapel implementiert). Systeme mit erstklassigen modifizierbaren Stapeln, wie Smalltalk, erfordern außerdem Spaghetti-Stapel oder ähnliches.


1

Das Betriebssystem muss einen zusammenhängenden Block angeben, wenn ein Stapel angefordert wird. Dies ist nur möglich, wenn eine maximale Größe angegeben wird.

Angenommen, der Speicher sieht während der Anforderung folgendermaßen aus (Xs stehen für "used", "Os unused"):

XOOOXOOXOOOOOX

Bei einer Anforderung für eine Stapelgröße von 6 antwortet das Betriebssystem mit Nein, auch wenn mehr als 6 verfügbar sind. Wenn Sie einen Stapel der Größe 3 anfordern, ist die Antwort des Betriebssystems einer der Bereiche mit 3 leeren Slots (Os) in einer Reihe.

Man kann auch die Schwierigkeit erkennen, Wachstum zuzulassen, wenn der nächste zusammenhängende Schlitz belegt ist.

Die anderen genannten Objekte (Listen usw.) werden nicht auf dem Stapel abgelegt, sondern landen in nicht zusammenhängenden oder fragmentierten Bereichen auf dem Haufen. Wenn sie also größer werden, benötigen sie nur Speicherplatz und keine zusammenhängenden Objekte wie sie sind anders gehandhabt.

Die meisten Systeme legen einen vernünftigen Wert für die Stapelgröße fest. Sie können ihn überschreiben, wenn der Thread erstellt wird, wenn eine größere Größe erforderlich ist.


1

Unter Linux ist dies lediglich eine Ressourcenbeschränkung, mit der außer Kontrolle geratene Prozesse abgebrochen werden, bevor sie schädliche Mengen der Ressource verbrauchen. Auf meinem Debian-System der folgende Code

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

erzeugt die Ausgabe

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

Beachten Sie, dass das Hard-Limit auf Folgendes festgelegt ist RLIM_INFINITY: Der Prozess kann das Soft-Limit auf einen beliebigen Wert erhöhen . Solange der Programmierer keinen Grund zu der Annahme hat, dass das Programm wirklich ungewöhnlich viel Stapelspeicher benötigt, wird der Prozess abgebrochen, wenn er eine Stapelgröße von acht Mebibyte überschreitet.

Aufgrund dieser Beschränkung wird ein außer Kontrolle geratener Prozess (unbeabsichtigte unendliche Rekursion) lange Zeit abgebrochen, bevor er so viel Speicher belegt, dass das System gezwungen ist, mit dem Auslagern zu beginnen. Dies kann den Unterschied zwischen einem abgestürzten Prozess und einem abgestürzten Server ausmachen. Programme mit einem legitimen Bedarf an einem großen Stapel werden jedoch nicht eingeschränkt. Sie müssen lediglich das Soft-Limit auf einen geeigneten Wert setzen.


Technisch gesehen wachsen Stacks dynamisch: Wenn das Soft-Limit auf acht Mebibyte festgelegt ist, bedeutet dies nicht, dass diese Speichermenge tatsächlich noch zugeordnet wurde. Dies wäre ziemlich verschwenderisch, da die meisten Programme nie an ihre jeweiligen Soft-Limits gelangen. Vielmehr erkennt der Kernel Zugriffe unterhalb des Stapels und ordnet die Speicherseiten nach Bedarf zu. Die einzige wirkliche Beschränkung für die Stapelgröße ist daher der verfügbare Speicher auf 64-Bit-Systemen (die Adressraumfragmentierung ist bei einer Adressraumgröße von 16 Zebibyte eher theoretisch).


2
Das ist der Stapel nur für den ersten Thread. Neue Threads müssen neue Stapel zuweisen und sind begrenzt, da sie auf andere Objekte stoßen.
Zan Lynx

0

Die maximale Stapelgröße ist statisch, da dies die Definition von "maximal" ist . Jede Art von Maximum für irgendetwas ist ein fest vereinbarter Grenzwert. Wenn es sich wie ein sich spontan bewegendes Ziel verhält, ist es kein Maximum.

Stacks auf Betriebssystemen mit virtuellem Speicher wachsen tatsächlich dynamisch bis zum Maximum .

Apropos, es muss nicht statisch sein. Sie kann vielmehr pro Prozess oder pro Thread konfiguriert werden.

Wenn die Frage lautet "Warum gibt es eine maximale Stapelgröße" (eine künstlich auferlegte, normalerweise viel weniger als verfügbarer Speicher)?

Ein Grund dafür ist, dass die meisten Algorithmen keinen enormen Stapelspeicherplatz benötigen. Ein großer Stapel ist ein Hinweis auf eine mögliche außer Kontrolle geratene Rekursion . Es ist gut, die außer Kontrolle geratene Rekursion zu stoppen, bevor der gesamte verfügbare Speicher zugewiesen wird. Ein Problem, das wie eine außer Kontrolle geratene Rekursion aussieht, ist die entartete Stapelverwendung, möglicherweise ausgelöst durch einen unerwarteten Testfall. Angenommen, ein Parser für einen binären Infix-Operator arbeitet mit der Rekursion des rechten Operanden: erster Operand analysieren, Scan-Operator, Rest des Ausdrucks analysieren. Dies bedeutet , dass die Stapeltiefe , die die Länge des Ausdrucks proportional ist: a op b op c op d .... Ein großer Testfall dieser Form erfordert einen großen Stapel. Wenn Sie das Programm abbrechen, wenn es ein angemessenes Stapellimit erreicht, wird dies erkannt.

Ein weiterer Grund für eine feste maximale Stapelgröße ist, dass der virtuelle Speicherplatz für diesen Stapel über eine spezielle Art von Zuordnung reserviert und somit garantiert werden kann. Garantiert bedeutet, dass der Speicherplatz nicht einer anderen Zuordnung zugewiesen wird, mit der der Stapel kollidiert, bevor das Limit erreicht wird. Der Parameter für die maximale Stapelgröße ist erforderlich, um diese Zuordnung anzufordern.

Threads benötigen aus ähnlichen Gründen eine maximale Stapelgröße. Ihre Stapel werden dynamisch erstellt und können nicht verschoben werden, wenn sie mit etwas kollidieren. Der virtuelle Speicherplatz muss im Voraus reserviert werden, und für diese Zuordnung ist eine Größe erforderlich.


@Lynn Hat nicht gefragt, warum die maximale Größe statisch ist. Er hat gefragt, warum sie vordefiniert ist.
Will Calderwood
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.