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.
- Stefan Baumgartner
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.
Videos by heise
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.
Manuelle Speicherverwaltung und ihre TĂĽcken
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.
Es kann nur einen geben: das Ownership-Modell in Rust
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:
- Jeder Wert in Rust hängt an einer Variable, die als Besitzer oder Owner bezeichnet wird.
- Es gibt exakt einen Besitzer fĂĽr jeden Wert.
- 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 Zustimmung 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.