Als systemnahe Programmiersprache erlaubt Rust, selbst festzulegen, wann und wie welche Art von Speicher zum Einsatz kommt. Das setzt etwas Wissen über Stack, Heap und Smart Pointer voraus.
Meine Kollegin Klara, ein großer Fan der Programmiersprache C#, hatte mit mir um ein Mittagessen gewettet, dass es mir nicht gelänge, in einem Artikel die grundlegende Speicherverwaltung von Rust zu erklären. Das Ergebnis lesen Sie bei Bedarf in Teil 6 der Planet-Rust-Reihe [1] nach. Als ich Klara zuletzt in der Kantine traf, sprach ich sie darauf an, wann ich mein Gratisessen bekäme.
Sie lächelte und gestand mir zu: “Der Artikel war gar nicht mal so schlecht und hat manches erklärt. Aber er hat doch nur an der Oberfläche gekratzt.” Ich konterte: “Das reicht für jede durchschnittliche Rust-Anwendung aus.” Nach einigem Hin und Her machte ich ihr den Vorschlag: “Gut, ich schreibe einen zweiten Artikel über die Details der Speicherverwaltung von Rust – und dann bekomme ich mein Gratisessen von dir.” Herausfordernd erwiderte Klara: “Mal sehen, wie der Artikel wird”, nahm ihr Tablett und ging.
High- und Low-Level
Hinter der Speicherverwaltung in einem Computer steckt ein komplexes Zusammenspiel von Hardware, Betriebssystem und Anwendungen. Wer sich damit im Detail beschäftigt, lernt nie ganz aus. Um Programme zu erstellen, genügt eine vereinfachte Vorstellung davon, ein Modell.
In der letzten Folge von Planet Rust ging es um das High-Level-Modell: Eine Variable entsteht, besitzt einen Wert und belegt dafür Speicher. Irgendwann existiert die Variable nicht mehr, und Rust gibt den Speicher dafür frei. Für Anwendungsprogramme erweist sich dieses Modell als ausreichend. Möchten Sie aber systemnah programmieren oder mit Mikrocontrollern arbeiten, sollten Sie mehr über Speicherverwaltung in Rust und das Low-Level-Modell wissen: Welche Arten von Speicher gibt es? Wozu dienen sie, und wie nutzen Sie die Varianten? Ich verwende die Beispiele aus der vorigen Planet-Rust-Folge und betrachte sie aus dem Low-Level-Blickwinkel.
Speicherbereiche
Rust kennt drei Arten von Speicher: den Stack, den Heap und den statischen Speicher (Static, siehe Tabelle “Speicherarten”). Wie dabei das Zusammenspiel zwischen Rust, dem Betriebssystem oder in der Hardware erfolgt, soll der Einfachheit halber außen vor bleiben.
Mit dem Stack arbeitet Rust wie mit einem Stapel Bücher: Es belegt neuen Speicher (ein neues Buch) immer oben. Zudem gibt es ihn grundsätzlich zuerst wieder frei (nimmt also das oberste Buch weg). Zum Verwalten des Stacks benötigen Sie nur einen einzigen Eintrag, der angibt, bis zu welcher Speicheradresse der Stack-Speicher belegt ist und was darüber frei ist.
Bei jedem Funktionsaufruf belegt Rust Speicher im Stack (Stack Frame), im Wesentlichen für die Argumente und lokalen Variablen der Funktion. Nach dem Abarbeiten der Funktion gibt Rust automatisch den Speicher im Stack wieder frei. Das ist der Grund, warum lokale Variablen lediglich vom Aufruf einer Funktion bis zu deren Ende existieren (Lifetime). Danach kann das Programm nicht mehr auf die Variable oder auf Referenzen der Daten zugreifen. Der Compiler legt bereits fest, wie viel Speicher vom Stack ein Funktionsaufruf belegt. Daher müssen zu diesem Zeitpunkt die Größe und Anzahl der Variablen bekannt sein.
Bei Vektoren und deren Daten trifft das nicht zu. Vektoren bestehen aus mehreren Elementen; ein Programm kann zur Laufzeit Elemente hinzufügen oder entfernen. Für solche dynamischen Daten eignet sich der Stack nicht. Hier greifen Sie auf den Heap-Speicher zurück, den Sie sich wie ein großes Warenlager für Daten vorstellen können. Das Programm braucht Platz für neue Daten. Die Heap-Verwaltung weist den Platz im Speicher zu. Sobald das Programm die Daten nicht mehr benötigt, informiert es den Heap, der den Speicher wieder freigibt.
Beim Stack erledigt Rust die ganze Arbeit automatisch, beim Heap kümmert sich das Programm darum. Prinzipiell lässt sich Speicher auf dem Stack schneller belegen und freigeben als im Heap. Die Heap-Verwaltung muss bei neuen Daten immer zuerst nach einem freien Platz mit passender Größe suchen. Zusätzlich erfordert die Freigabe etwas Buchhaltung.
Neben Stack und Heap verwendet Rust statischen Speicher, den es beim Laden des Programms belegt und erst am Programmende wieder freigibt. Im statischen Speicher liegt das Programm selbst als kompilierter Binärcode. Hinzu kommen nicht veränderbare Zeichenketten vom Typ »str« und Variablen, die als »static« definiert sind. Bei der Zuweisung »static wert:i32 = 5;« existiert die Variable »wert«, solange das Programm selbst sich im Speicher befindet. Theoretisch kann man statische Variablen sogar verändern – das ist allerdings kompliziert und nicht zu empfehlen.
|
Stack |
Heap |
Static |
||
|---|---|---|---|---|
|
|
Inhalt |
Argumente der Funktion, lokale Variablen |
verschiedene Daten, variable Länge, beliebig lang |
Programm, statische Variablen, String-Literale (»str«) |
|
Lifetime |
solange Rust die Funktion abarbeitet |
durch das Programm festgelegt |
solang das Programm vorhanden ist |
|
|
Größe |
dynamische Belegung mit fester Größe |
dynamisch |
feste Größe |
|
|
Freigabe |
automatisch beim Funktionsende |
durch das Programm |
automatisch beim Programmende |
Unter der Lupe
Theoretisch lässt sich all das leicht überschauen. Schwierig wird es, sobald Sie die Abläufe in der Speicherverwaltung bei einem konkreten Programm rekonstruieren möchten – vor allem, wenn etwas schiefläuft. Mir hat dabei das kostenlose Werkzeug Aquascope [2] geholfen. Es visualisiert automatisch die Belegung des Speichers (Abbildung 1).
Versuchen Sie zunächst selbst, für ein einfaches Programm (Listing 1) nachzuvollziehen, was Rust im Stack macht und was im Heap des Speichers. Die Stellen im Programm, die im Kommentar mit »L« und einer Zahl gekennzeichnet sind, ändern auf jeden Fall etwas am Speicher.
Listing 1
Abläufe in der Speicherverwaltung
struct Book{
name: String,
pages: i32
}
fn main() { //L1
let number = 5;
let anton = Book{name: "Alice im Wunderland".to_string(), pages:120};
//L3
let berta = anton; //L4
} //L5
Abbildung 2 visualisiert die Abläufe. An Position L1 startet Rust die Funktion »main« (Zeile 5). Da sie keine Argumente besitzt, passiert nicht viel auf dem Stack.
An Position L2 erzeugt das Programm die lokalen Variablen »number« und »anton« (ab Zeile 6), die beide auf den Stack wandern. Der Wert der Variablen »number« landet unverändert dort, da es sich um eine Zahl handelt. Egal, welchen Wert die Variable »number« annimmt, die Größe des belegten Speichers bleibt stets gleich. Die Variable »anton« (Zeile 7), die eine »struct« vom Typ »Book« enthält, legt Rust ebenfalls auf den Stack – allerdings nicht den String, auf den der Name verweist. Der landet auf dem Heap, da es sich um einen Datentyp handelt, dessen Länge sich ändern kann. Auf dem Stack befindet sich lediglich der Verweis auf die entsprechende Stelle im Heap.

Abbildung 2: Eine Visualisierung der Abläufe im Speicher für das Programm aus Listing 1.
An Position L4 weist das Programm der lokalen Variablen »berta« die Variable »anton« zu (Zeile 9). Daraufhin erscheint »berta« auf dem Stack, und »anton« verliert seine Gültigkeit. Wie in der letzten Folge beschrieben, kann in Rust ausschließlich eine Variable der Owner (Besitzer) der Daten sein. Die Ownership geht an dieser Stelle von »berta« auf »anton« über. Würde das Programm nach der Zuweisung noch einmal versuchen, auf die Variable »anton« zuzugreifen, hätte das eine Fehlermeldung im Compiler zur Folge.
Bei Position L5 angekommen, hat Rust die Funktion »main« abgearbeitet. Alle lokalen Variablen verschwinden vom Stack, und Rust entfernt sämtliche Daten vom Heap, auf die Variablen verwiesen haben.
Die Low-Level-Speicherverwaltung sieht im ersten Moment ein wenig komplex aus. Allerdings gewöhnen Sie sich schnell daran, wenn Sie versuchen, das Speichermanagement mit einfachen Programmen auf dieser Ebene nachzuvollziehen.
Structs klonen
Die Ownership in Bezug auf Basistypen wie Ganzzahlen fällt unkompliziert aus, da Rust deren Werte automatisch kopiert. Bei anderen Datentypen besteht die Möglichkeit, sie mit der Schnittstelle (Trait) »Clone« auszustatten. Das funktioniert ähnlich wie das Kopieren (Trait »Copy«) bei Basistypen.
In der letzten Zeile von Listing 2 (Position L6) nimmt Rust nicht einfach die Daten der Variable »anton« und schiebt (»Move«) sie an die Variable »berta« weiter, sondern erstellt eine Kopie der Daten. Das erfolgt sowohl auf dem Stack als auch auf dem Heap.
Listing 2
Clone
#[derive(Clone)]
struct Book{
name: String,
pages: i32
}
fn main() {
let anton = Book{name: "Alice im Wunderland".to_string(), pages:120};
let berta = anton.clone(); //L6
}
Verleihen
Rust erlaubt dem Owner eines Werts, ihn zu verleihen, ohne die Ownership aufzugeben (Borrowing). Das funktioniert über Referenzen (Abbildung 3). In Listing 3 verwendet die Funktion »drucke()« eine Referenz auf den Datentyp »Book« als Argument: Sie borgt sich den Wert. Dadurch bleibt die Ownership bei der Variable »anton«, und das Programm kompiliert ohne Fehlermeldung. Eine Referenz auf einen bestimmten Datentyp drücken Sie durch »&« (kaufmännisches Und) vor dem Datentyp aus.

Abbildung 3: Eine Visualisierung der Abläufe im Speicher für das Programm aus Listing 3.
Listing 3
Borrowing
fn drucke(b:&Book){
println!("Buch {0}", b.name);
}
fn main() {
let anton = Book{name: "Alice im Wunderland".to_string(), pages:120};
drucke(&anton);
}
Smart Pointer
Im Zusammenhang mit der Speicherverwaltung fällt bei Rust häufig der Begriff Smart Pointer. Daneben tauchen Speicheradressen, Pointer und Referenzen auf. Zugegeben, das alles auseinanderzuhalten, gelingt auf Anhieb nicht so leicht.
Hinter einer Speicheradresse (Memory Address) verbirgt sich eine Zahl, die auf ein bestimmtes Byte im Speicher zeigt. Ein Pointer dagegen verweist als Speicheradresse auf einen Wert mit einem bestimmten Datentyp. Eine Referenz entspricht prinzipiell einem Pointer, für den Rust garantiert, dass er immer auf einen Wert verweist und nie ins Leere zeigt.
Ein Smart Pointer kann jedoch noch mehr als eine Referenz. Er bringt zusätzliche Funktionen mit und ist ein Datentyp mit dynamischer Größe. Rust nutzt dafür immer Speicher auf dem Heap. Zwei Smart Pointer habe ich schon oft in dieser Artikelreihe eingesetzt: »Vec« und »String«. Beide können dynamisch wachsen und liegen daher auf dem Heap. Drei weitere Smart Pointer, »Box«, »Rc« und »Arc«, finden in Rust ebenfalls häufig Verwendung.
Box, Rc und Arc
Der Datentyp »Box« verfügt über nicht allzu viele Funktionen. Sie greifen auf ihn zurück, wenn Sie Daten auf dem Heap und nicht auf dem Stack speichern möchten. Dabei packen Sie quasi beliebige Daten in eine Schachtel (Box) auf dem Heap.
In den ersten beiden Zeilen von Listing 4 liegt der Wert »5« der Variable »anton« auf dem Stack, der Wert »5« der Variablen »berta« dagegen auf dem Heap. Wie Sie im Befehl »println!« in der dritten Zeile sehen, können Sie die Variable »berta« genauso verwenden wie »anton«. Den Datentyp »Box« können Sie nicht klonen. Allerdings lässt sich die Ownership verschieben (»Move«).
Der nächste Smart Pointer heißt »Rc« (Reference Counted). Er zählt mit, wie viele Referenzen es auf ihn gibt. Sobald sich keine mehr finden, löscht Rust den Speicher automatisch vom Heap. Seine zusätzliche Funktion ermöglicht, dass Daten gleichzeitig mehrere Owner aufweisen. Hier zeigt sich der wesentliche Einsatzbereich für den Datentyp »Rc« (Listing 4, ab Zeile 4).
Listing 4
Box und Rc
let anton :i32 = 5;
let berta: Box<i32> = Box::new(5);
println!("{}",berta);
let anton = Rc::new(5);
println!("{}",Rc::strong_count(&anton)); //Ergebnis 1
let berta = anton.clone();
println!("{}",Rc::strong_count(&anton)); //Ergebnis 2
Bei der Methode »clone« (Zeile 6) erzeugt Rust keine Kopie der Daten. Stattdessen verweist die Variable »berta« auf dieselben Daten wie »anton«. Rust erhöht den Referenzzähler um eins. Mit der Methode »Rc::strong_count« (Zeilen 5 und 7) fragen Sie ab, wie viele Referenzen aktuell auf die Daten zeigen.
Bei paralleler Verarbeitung (Multithreading) lässt sich »Rc« nicht nutzen. Dann kommt »Arc« (Atomic Reference Counted) ins Spiel, mit dem mehrere Owner über mehrere Threads mit den Daten arbeiten können (Abbildung 4).

Abbildung 4: Der Datentyp »Arc« erlaubt, dass mehrere Owner über mehrere Threads mit den Daten arbeiten.
Neben den hier vorgestellten Smart Pointern gibt es jede Menge weiterer, sie können sogar selbst welche erstellen. Bei einem Smart Pointer handelt es sich meistens um eine »struct«, die zwei Schnittstellen implementiert: »Deref« und »Drop«. Erstere kümmert sich bei Zugriffen um den Wert, Letztere schaltet sich ein, wenn es den Wert zu löschen gilt.
Fazit und Ausblick
In die Speicherverwaltung von Rust lässt sich stets noch tiefer einsteigen – vor allem, wenn Sie Mikrocontroller oder andere spezielle Hardware verwenden. Ich hoffe, meiner Kollegin Klara war diese Planet-Rust-Folge ausführlich genug.
Im vergangenen Dezember habe ich zum ersten Mal an dem klassischen Programmierwettbewerb AdventOfCode teilgenommen. Dabei schaffte ich es zwar nicht unter die ersten 100, aber einmal landete ich unter den ersten 8000. Mehr dazu und zu Iteratoren, Vektoren und funktionaler Programmierung lesen Sie in der nächsten Folge von Planet Rust. (csi)
Infos
- Planet Rust (Folge 6): Gerhard Völkl, “Rohstoffspeicher”, LM 01/2024, S. 90, https://www.lm-online.de/49905
- Aquascope: https://github.com/cognitive-engineering-lab/aquascope






