Ferris Talk #11: Memory Management – Speichermanagement in Rust mit Ownership

Wer das Ownership-Modell und den Borrow Checker in Rust einmal verstanden hat, dem stehen die Tore offen für speicher- und laufzeiteffizientes Programmieren.

In Pocket speichern vorlesen Druckansicht 36 Kommentare lesen
Ferris Talk – Neuigkeiten zu Rust. Eine Heise-Kolumne von Rainer Stropek und Stefan Baumgartner für Rustaceans
Lesezeit: 12 Min.
Von
  • Stefan Baumgartner
Inhaltsverzeichnis

Die Programmiersprache Rust bringt viele Konzepte in den Mainstream, die sonst nur in Nischensprachen oder akademischen Veröffentlichungen vorkommen. Dabei handelt es sich nicht nur um Feinheiten, sondern um fundamentale Eigenschaften der Sprache. Eines dieser Konzepte ist so einzigartig, dass es für viele absolutes Neuland darstellt: die Speichersicherheit ohne Garbage Collector.

Jede Software muss in irgendeiner Weise Speicher verwalten. Zwei Modelle haben sich dazu durchgesetzt: zum einen die automatische Speicherverwaltung durch einen Garbage Collector, zum anderen das eigenverantwortliche Verwalten durch die Entwicklerin oder den Entwickler selbst.

Vereinfacht dargestellt führt die automatische Speicherverwaltung durch eine Laufzeitumgebung Buch über die Anzahl der Nutzer einer Speichereinheit. Merkt die Runtime, dass es keine Variablen mehr gibt, die auf den Speicherbereich zeigen, kommt der sprichwörtliche Müllmann und gibt den reservierten Speicher wieder frei.

Mit Java ist Garbage Collection in den 1990er Jahren populär geworden, und heutzutage ist diese Art der Verwaltung wohl die verbreitetste. Der größte Vorteil ist, dass Speicherlecks eher selten vorkommen, was zu sichererer Software führt. Für geschwindigkeitskritische Systeme wirkt sich die Laufzeitumgebung nachteilig aus, da sie konstant im Hintergrund Speicherbewegungen und Zeiger auf Speicherbereiche überprüfen muss.

Ferris Talks – die Kolumne für Rustaceans

Die andere Art überträgt die Verantwortung der Speicherverwaltung den Programmierern und Programmiererinnen. Sie müssen durch Befehle explizit Speicherbereiche anfordern und an anderer Stelle wieder freigeben. C und C++ sind die populärsten Vertreter dieser Art des Memory Managements. Hier ein Beispiel, wie sich in C mit malloc und free Speicher für drei aufeinanderfolgende Ganzzahlen anfordern, befüllen und anschließend freigeben lässt:

#include <stdio.h>
#include <stdlib.h>

int main() {
    int* ptr;
    int n = 3;
    // Speicher anlegen
    ptr = (int*) malloc(n * sizeof(int));

    // Befuellen
    for(int i = 0; i < n; i++) {
        ptr[i] = i;
    }
  
    for(int i = 0; i < n; i++) {
        printf("%d ", ptr[i]);
    }

    printf("\n");

    // Freigeben
    free(ptr);
}

Listing 1: Dynamische Speicherreservierung und Befüllung von drei Ganzahlen in C

So weit, so umständlich. Manuelles Speichermanagement ist klarerweise mit erheblichem Aufwand verbunden. Es hat allerdings den Vorteil, dass nur der Speicher allokiert wird, der auch tatsächlich verwendet wird. So lassen sich besonders speicher- und laufzeiteffiziente Programme schreiben.

Die Nachteile überwiegen jedoch und haben in der Branche sogar eigene Bezeichnungen erhalten:

  • Speicherlecks. Den Speicher, den man einmal angefordert hatte, muss man später auch wieder aktiv freigeben. Ansonsten kann es schnell zu Lecks und einem überfüllten Speicher kommen.
  • Was, wenn man Speicher zu früh freigibt? Ein Use-after-free-Fehler besagt, dass man auf bereits freigegebenen Speicher zuzugreifen versucht. Das führt entweder zu korrumpierten Daten oder zu Programmabstürzen.
  • Andererseits kann man mit einem Double-free-Fehler Speicher auch mehr als einmal freigeben.
  • Mit Buffer overreads und overwrites schießt man wortwörtlich über das Ziel hinaus und liest und schreibt in Bereichen, die einem gar nicht gehören.

Diese Probleme sind nicht nur höchst unangenehm und können zu Programmabstürzen führen, sondern sind auch sicherheitskritisch. Die Einträge CWE-401, CWE-415 und CWE-416 der Common Weakness Enumeration zeigen, welche Angriffsvektoren durch Probleme beim manuellen Speichermanagement entstehen können.

Diese Probleme sind zahlreich. So beschreiben sowohl Microsoft als auch Google, dass 70 Prozent der sicherheitskritischen Bugs in Windows sowie Chrome auf Probleme der Speichersicherheit zurückzuführen sind.

Um diese Problemkategorie kümmert sich Rusts Speichermodell. Statt automatisierter und dadurch langsamer Speicherverwaltung durch eine Laufzeitumgebung oder manueller, aber höchst aufwendiger und sicherheitskritischer Verwaltung durch Menschenhand gibt es in Rust eine dritte Art der Speicherverwaltung: das Ownership-Modell.

Im Ownership-Modell übernimmt der Compiler die nötigen Anweisungen, um Speicher zu allokieren und wieder freizugeben, fordert aber von Entwicklern und Entwicklerinnen die Einhaltung eines einfachen, dafür tiefgreifenden Regelwerks:

  1. Jeder Wert in Rust hängt an einer Variable, die als Besitzer oder Owner bezeichnet wird.
  2. Es gibt exakt einen Besitzer für jeden Wert.
  3. Ist die besitzende Variable nicht mehr im Ausführungskontext (engl. Scope) enthalten, der meist gekennzeichnet ist durch die geschwungene Klammer am Ende eines Blocks, wird der zuvor reservierte Speicher freigegeben.

Empfohlener redaktioneller Inhalt

Mit Ihrer Zustimmmung wird hier ein externes YouTube-Video (Google Ireland Limited) geladen.

Ich bin damit einverstanden, dass mir externe Inhalte angezeigt werden. Damit können personenbezogene Daten an Drittplattformen (Google Ireland Limited) übermittelt werden. Mehr dazu in unserer Datenschutzerklärung.

Speicher wird bei der Initialisierung einer Variable reserviert. Dieses Konzept heißt RAII (Resource acquisition is initialisation) und ist auch aus C++ bekannt. Für einfache Programme kann das wie im folgenden Listing aussehen:

fn main() {
    // Speicher fuer vec wird hier angelegt
    let vec = vec![0, 1, 2]; 

    println!("{:?}", vec);
} // vec wird hier freigegeben.

Listing 2: Dynamische Speicherreservierung und Befüllung für drei Ganzzahlen in Rust mit dem vec-Makro

Beim Versuch, denselben Wert einer anderen Variable zuzuweisen – was in beinahe jeder anderen Programmiersprache problemlos funktioniert – verstößt man gegen die wichtigste Regel des Ownership-Modells: "Es kann nur einen geben" (gemeint ist der Besitzer). Listing 3 veranschaulicht das Prinzip.

fn main() {
    let vec = vec![0, 1, 2]; // Speicher für vec wird hier angelegt
    let another_vec = vec; // Zuweisung
    println!("{:?}", vec); // vec ist nicht mehr Besitzer der Daten
}

Listing 3: Fehlerhafter Versuch, auf Daten zuzugreifen, die bereits einen anderen Besitzer haben

Der Compiler übersetzt das Beispiel nicht. Zum Zeitpunkt, zu dem vec auf die Kommandozeile auszugeben wäre, ist schon another_vec Besitzer der angelegten Daten. Der Rust-Compiler produziert einen Fehler und teilt in einer Hinweisnachricht mit, dass auf einen Wert zugegriffen wird, der "schon bewegt wurde" – das ist Jargon für die Zuweisung zu einer anderen Variable.

Das gleiche Prinzip gilt, wenn ein allokierter Wert an eine Funktion übergeben wird, die in der Funktionssignatur den Besitz anfordert, wie Listing 4 zeigt:

fn main() {
    let vec = vec![0, 1, 2];
    print_vec(vec);
}

fn print_vec(vec: Vec<i32>) {
    println!("{:?}", vec);
}

Listing 4: Die Funktion print_vec fordert den Besitz des Vektors an

Beim Aufruf von print_vec hat main den Besitz von vec an die Funktion abgegeben. Am Ende der print_vec-Funktion – und somit am Ende des Ausführungskontexts – wird der Speicherbereich aller Werte freigegeben, die im Besitz von print_vec sind. Ein weiterer Aufruf von print_vec mit vec würde einen Compiler-Fehler verursachen, da die Originaldaten bereits vernichtet wurden.