Speicher, Heap, Stack, Loader

Bei der Ausführung eines in C oder C++ programmierten Programmes wird vom System ein sogenannter Prozess gestartet. In modernen Computersystemen wird jedem Prozess ein eigener, grosszügiger Speicherplatz zugeordnet, der in mehrere Segmente aufgeteilt wird. Diese Segmente speichern zum einen statische und dynamische Daten, beinhalten zum anderen aber auch den ausführbaren Maschinencode und das Runtime-System. Der grösste Teil des Speicherplatzes ist heutzutage derjenige für die dynamischen Daten. Er wird Heap (zu Deutsch: Halde) genannt und steht dem Prozess grundsätzlich (mit einigen Restriktionen) zur freien Verfügung.

Die Abarbeitung des Maschinencodes wird in jedem Prozess von einem oder mehreren Threads (zu Deutsch: Fäden) übernommen. Beim Start eines Prozesses existiert üblicherweise nur genau ein Thread, doch durch Aufruf von bestimmten Bibliotheksfunktionen können neue, parallele Threads gestartet werden. Jeder Thread operiert auf demselben, dem Prozess zur Verfügung gestellten Heap, doch die einzelnen Threads sind unabhängige Ausführungseinheiten und müssen für einen selbständigen Ablauf somit eine speziell für den jeweiligen Thread reservierte Datenstruktur besitzen, auf denen sie lokale Daten speichern können. Diese Datenstruktur wird als Stack bezeichnet.

Das Aufsetzen all dieser Speicherstrukturen sowie das korrekte Initialisieren der verschiedenen Speichersegmente und des Runtime-Systems übernimmt ein sogenannter Loader.

Details

Der Heap bezeichnet den insgesamt einem Prozess zur Verfügung stehenden Speicher. Auf modernen 32-Bit-Systemen können von jedem einzelnen Prozess bis zu 4 Gigabyte Speicher angesprochen werden, auf 64-Bit-Systemen sogar um ein Milliardenfaches mehr. Gewisse Teile des Heaps sind jedoch für das Runtime-System reserviert, welches einen sicheren Ablauf des Programmes und eine kollisionsfreie Speicherung der Daten garantiert.

Ein Teil des reservierten Heaps beinhaltet das lauffähige Maschinenprogramm, welches vor dem Start des Prozesses von einem sogenannten Loader von der Festplatte in den Speicher kopiert wird, damit es während der Ausführung stets verfügbar ist. Dieser Teil wurde früher als das sogenannte Text-Segment bezeichnet. Nach dem Laden des Text-Segmentes reserviert und initialisiert der Loader Platz für die globalen Variablen. Bevor der Loader dem eigentlichen Prozess die Kontrolle übergibt, reserviert und initialisiert er zudem die notwendigen Datenstrukturen für das Runtime-System, unter anderem auch den Stack für den ersten (Main) Thread.

Die Grösse eines Stacks ist je nach System und Architektur unterschiedlich und kann teilweise sogar manuell festgelegt werden. Standardmässig sind Stacks heutzutage einige Dutzend Kilobyte bis ein paar wenige Megabyte gross.

Während der Ausführung eines Threads werden ständig Funktionen aufgerufen oder es wird von Funktionen zurückgesprungen. Jede aufgerufene Funktion muss somit die Information speichern, in welchem Zustand sich der Prozessor vor Aufruf der Funktion befand. Dies beinhaltet zum einen die Datenregister, aber auch beispielsweise die Adresse des nächsten nach dem Rücksprung auszuführenden Maschinenbefehls, sprich: Die Rücksprungadresse. Ausserdem kann jede Funktion lokale Variablen speichern und zudem einen Wert an die übergeordnete Funktion zurückgeben. Da sämtliche Funktionsaufrufe hierarchisch gegliedert sind, eignet sich für die Speicherung und Organisation all dieser Daten ein Stack, welcher auch als der Call-Stack bezeichnet wird.

Die aktuelle Position innerhalb des Stacks ist durch einen sogenannten Stack-Pointer gegeben, wofür häufig ein eigens dafür reserviertes Prozessor-Register existiert. Vereinfacht gesagt wird beim Aufruf einer Funktion der Stack-Pointer so innerhalb des Stacks verschoben, dass genügend Platz für sämtliche Zustandsdaten und lokale Variablen geschaffen wird. Beim Rücksprung aus einer Funktion werden die an diesen Stellen gespeicherten Daten wieder in die dafür vorgesehenen Register kopiert und der Stack-Pointer an die vorangegangene Adresse zurückverschoben. Eine etwas detailiertere Ausführung kann beim Call-Stack nachgelesen werden.

Allokationen

Unter Allokation wird das Reservieren einer gewissen Menge an Bytes innerhalb des gesamten zur Verfügung stehenden Speicherplatzes verstanden. Sowohl im Heap als auch auf dem Stack können Allokationen stattfinden, wobei manche Allokationen implizit durch die Sprache selbst vorgenommen werden und andere explizit ausprogrammiert werden müssen. Bei Allokationen ist zu unterscheiden zwischen Datenblöcken und Variablen.

Datenblöcke

Datenblöcke können nur auf dem Heap alloziiert werden. Der gewünschte Speicherplatz muss explizit durch den Aufruf der malloc-Funktion angefordert werden. Diese durch die Sprachen C und C++ gegebenen Methoden nutzen das Runtime-System, um einen freien Datenblock mit der gewünschten Grösse im Heap aufzufinden und zu reservieren. Das Runtime-System überprüft, ob genügend Platz vorhanden ist und stellt sicher, dass sich keine zwei reservierten Blöcke überschneiden. Des weiteren werden bislang uninitialisierte Blöcke mit Nullen überschrieben, sodass keine Prozess-fremden Daten verfügbar sind. Für jede Allokation wird somit eine nicht zu vernachlässigende Menge an Zeit benötigt.

Um den Speicher wieder freizugeben (Dealloziieren), muss explizit die free-Funktion verwendet werden. Auch für das Dealloziieren benötigt das Runtime-System eine gewisse Menge an Zeit.

Bemerkung: Sowohl der new-Operator, als auch die malloc-Funktion kann für beliebige Daten verwendet werden und jeweilige Datenblöcke können ohne Probleme nebeneinander im selben Prozess existieren. Dennoch unterscheidet das Runtime-System zwischen den beiden Methoden, sodass mit new alloziierte Blöcke im Allgemeinen nicht mit der free-Funktion dealloziiert werden können und vice versa.

Variablen

Eine Definition einer Variablen bewirkt die Bereitstellung von Daten mit dem angegebenen Typ. Es ist zu beachten, dass eine Variable nicht nur einen Basistyp, sondern auch beispielsweise ein ganzes Array beschreiben kann. Eine einzelne Variable kann somit beliebig gross sein. Wo dass dieser für die Variable notwendige Speicherplatz alloziiert wird, wird durch die Speicherklasse der Variablen festgelegt.

Auf dem Stack werden ausschliesslich Variablen der auto-Speicherklasse gespeichert. Diese Speicherklasse ist die Standard-Klasse für Variablen, welche innerhalb von Funktionen definiert werden, somit gehören auch Variablendefinitionen innerhalb einer Funktion ohne Angabe einer Speicherklasse automatisch zur auto-Speicherklasse. Da solche Variablen nur gerade für die aktuelle Funktion Gültigkeit besitzen, werden sie auch als lokale Variablen bezeichnet.

Bei Variablen der auto-Speicherklasse passiert die Allokation oder Deallokation automatisch, daher auch der Name. Die Allokation und Deallokation von genügend Speicherplatz wird automatisch durch die Verschiebung des Stack-Pointers erledigt. Der Vorteil hierbei ist, dass sowohl Allokation als auch Deallokation keinerlei Zeit benötigt.

Variablen der static-Speicherklasse werden im Heap gespeichert. Die Allokation des Speicherplatzes wird bereits während des Ladevorganges des Programmes vorgenommen, indem der Loader vor Beginn des Prozesses automatisch genügend Speicher zur Verfügung stellt.

Gehört eine Variable zur register-Speicherklasse, wird sie weder im Heap noch im Stack gespeichert. Die register-Speicherklasse bewirkt, dass eine Variable in einem Prozessor-Register gespeichert wird. Sollte dies (aus verschiedenen Gründen) nicht möglich sein, wird die Variable automatisch in die auto-Speicherklasse verschoben.

Bei der Angabe der extern-Speicherklasse findet die Allokation und Deallokation explizit an einer anderen Stelle statt. Die Angabe der Variablen dient in diesem Fall als reine Deklaration.

Die typedef-Speicherklasse ist keine richtige Speicherklasse und alloziiert somit auch keine Daten, sondern legt nur einen neuen Typ fest.

Detailierte Informationen können bei den jeweiligen Speicherklassen nachgelesen werden.

Auswirkungen auf den Programmierstil

Der grosse Vorteil des Heaps ist seine Grösse. Während auf dem Stack vielleicht gerade mal einige Kilobyte für Variablen verwendet werden können, kann der Heap mehrere Gigabyte an Datenblöcken speichern. Der grosse Vorteil vom Stack ist jedoch, dass Allokationen keinerlei Zeitaufwand benötigen. Aufgrund dieser Vor- und Nachteile sollte stets genau überlegt werden, wie und wo Allokationen stattfinden sollen.

Häufige Aufrufe von new und delete, beziehungsweise von malloc und free können die Performance eines Prozesses stark beeinflussen. Das Runtime-System benötigt bei jedem einzelnen Aufruf eine nicht zu vernachlässigende Menge an Zeit, um den Heap zu verwalten. Dem kann entgegengewirkt werden, indem beispielsweise häufig verwendete Objekte nur an einem einzigen Ort alloziiert werden und bei Bedarf angesprochen und gegebenfalls mittels einer entsprechenden Methode initialisiert werden können. Eine andere Möglichkeit ist die gleichzeitige Allokation von einer ganzen Menge an Objekten, welche alsdann in einer geeigneten Datenstruktur (Beispielsweise in einem Array, einer Liste oder einem Pool) gespeichert und verwaltet werden.

Es ist zu beachten, dass für die Allokation von Variablen wenig bis gar kein Zeitaufwand betrieben werden muss. Eine Initialisierung von Variablen oder aber gar der Aufruf eines Konstruktors nimmt jedoch unabhängig davon nach wie vor Zeit in Anspruch. Auch Aggregats-Zuweisungen können sehr zeitaufwendig sein und führen bei häufigen Funktionsaufrufen zu einem starken Performance-Verlust. Es ist somit zu empfehlen, Aggregats-Zuweisungen und aufwendige Konstruktoren innerhalb von häufig aufzurufenden Funktionen wenn immer möglich zu vermeiden.

Aufgrund des Geschwindigkeitsvorteils ist es häufig empfehlenswert, möglichst viele Daten (beispielsweise ganze Arrays) in lokale oder globale Variablen zu verpacken. Da lokale Variablen jedoch auf dem Stack gespeichert werden, ist die Grösse stark begrenzt und kann im schlimmsten Falle gar zum Absturz des Prozesses führen, da aufgrund des fehlenden Platzes auf dem Stack nicht mehr so viele Funktionsaufrufe ausgeführt werden können. Ausserdem kann durch eine zu starke Benutzung des Stacks die durch den Prozessor gegebene Caching-Struktur nutzlos werden, was zu starken Performance-Einbussen führt.

Die Verwendung von grossen globalen Variablen führt grundsätzlich zu einer verlängerten Ladezeit. Der Loader muss gewährleisten, dass keine Prozess-fremde Daten verfügbar sind, weswegen er sämtliche globalen Variablen initialisieren muss. Wird keine Initialisierung angegeben, so füllt der Loader die Variablen mit lauter Nullen.

Es ist zu beachten, dass moderne Compiler möglicherweise automatische Optimierungen vornehmen, wie beispielsweise, dass allzu grosse lokale Variablen automatisch mittels des Runtime-System im Heap alloziiert werden. Auch die Entscheidung, welche Variable in eine register-Speicherklasse gehört, wird je nach Compiler und Architektur unterschiedlich gehandhabt.

Nächstes Kapitel: Call-Stack