Aus Linux-Magazin 01/2024

Sichere Speicherverwaltung ohne Performance-Einbußen

© geckophotos / 123RF.com

In Rust garantiert der Compiler eine sichere Speicherverwaltung – einer der zentralen Pluspunkte der Sprache. In diesem Zusammenhang fallen oft die Begriffe Ownership und Borrowing. Das klingt kompliziert, ist es aber nicht.

Meine Kollegin – ich nenne sie der Einfachheit halber Clara – gehört zu den größten Fans der Programmiersprache C# in meinem Umfeld. Beim gemeinsamen Mittagessen in der Firma veranstalteten wir neulich spaßeshalber einen kleinen Wettstreit: Ich erklärte ihr die Vorteile von Rust, und sie versuchte, mich von C# zu überzeugen.

“In C# kümmert mich die Speicherverwaltung gar nicht. Das macht alles die Programmiersprache”, zählte dabei zu ihren Lieblingsargumenten. Ich hielt dagegen: “Dafür rattert irgendwann die C#-Speicherbereinigung los, und das Programm wird ewig langsam.” Darauf konterte Clara: “Aber in Rust ist alles zu kompliziert mit Ownership und so. Das versteht doch niemand!”

Irgendwann reichte es mir, und ich schlug ihr eine Wette vor: “Das ist keineswegs kompliziert. Ich wette mit dir um ein Mittagessen, dass ich in einer einzigen Planet-Rust-Episode dir und allen anderen Lesern die komplette Speicherverwaltung von Rust vermitteln kann.” Sie lächelte cool und streckte mir die Hand entgegen: “Abgemacht!”

Herangehensweisen

Die erste wichtige Frage in diesem Kontext lautet: Warum braucht ein Programm überhaupt eine Speicherverwaltung? Die Antwort fällt relativ simpel aus: Der Speicher ist nicht unendlich groß. Deswegen bietet es sich an, Bereiche wiederzuverwenden, die ein Programm irgendwann einmal benutzt hat, aktuell aber nicht mehr benötigt. In Programmiersprachen wie C gibt es für das Belegen und Freigeben von Speicher spezielle Befehle, um deren Aufruf sich der Programmierer kümmern muss. Er trägt dafür die volle Verantwortung. Dadurch entsteht zwar kein zusätzlicher Aufwand im Programmablauf, aber es steigt das Risiko, dass der Entwickler etwas verkehrt macht.

Sprachen wie Java oder C# sind das andere Extrem: Hier kümmert sich die Programmiersprache um alles. Ein Garbage Collector überprüft, ob er Speicher wiederverwenden kann. Das kostet allerdings Performance. Im Gegenzug braucht sich der Programmierer jedoch nicht damit zu beschäftigen und kann zudem keine Fehler machen.

Rust hat sich für einen Weg entschieden, der absolute Sicherheit garantiert und dabei die Ausführungsgeschwindigkeit im Blick behält. Die Vorgehensweise siedelt sich irgendwo zwischen den zwei beschriebenen Extremen an.

Ownership

In Rust gibt es drei Regeln, nach denen die Speicherverwaltung automatisch abläuft:

  • Regel 1: Jeder Wert und der Speicher, den er belegt, gehört einer Variablen. Sie ist der Owner des Werts.
  • Regel 2: Zu einem bestimmten Zeitpunkt kann es ausschließlich einen Owner des Werts geben.
  • Regel 3: Wenn der Owner, nicht (mehr) existiert (engl.: goes out of scope) – etwa am Ende einer Funktion, die die betreffende Variable erzeugt hat – gibt Rust den Speicher für den Wert automatisch frei.

Mit diesen Regeln habe ich bereits die komplette Speicherverwaltung von Rust beschrieben. Aber wie so oft bei Regeln muss man ihre Bedeutung und Auswirkungen erst einmal verstehen. Dafür nutzen wir das einfache Programm aus Listing 1.

Die Datenstruktur »Book« besteht aus dem Namen des Buchs (»String«) und der Anzahl der Seiten (Integer, »i32«). Der Befehl »let« (Zeile 6) erzeugt eine Version der Datenstruktur »Book«, belegt dafür Speicher und weist sie der Variablen »anton« zu. Damit steht »anton« als Owner der Daten fest (Regel 1). Die Variable existiert bis zum Ende der Funktion »main()« (Zeilen 5 bis 8). Gemäß Regel 3 gibt Rust danach den Speicher der Datenstruktur »Book« automatisch wieder frei. Eigentlich alles ganz einfach.

Listing 1

Ownership

struct Book{
  name: String,
  pages: i32
}
fn main() {
  let anton = Book{name: "Alice im Wunderland".to_string(), pages:120};
  println!("{0}", anton.name);
}

Sehen Sie sich als Nächstes bitte die Variante des Beispielprogramms in Listing 2 an. Hier kommt eine Zeile hinzu, die den Wert der Variablen »anton« der Variablen »berta« zuweist (Zeile 3). Wenn Sie jetzt das Programm kompilieren, wirft der Compiler mit Fehlermeldungen um sich (Abbildung 1), beispielsweise »Error: borrow of moved value: ‘anton’«.

Listing 2

Ownership-Wechsel

fn main() {
  let anton = Book{name: "Alice im Wunderland".to_string(), pages:120};
  let berta = anton;
  println!("{0}", anton.name);
}
Abbildung 1: Sobald Sie den Wert der Variablen »anton« der Variablen »berta« zuweisen, gerät der Compiler ins Stolpern und meldet einen Fehler.

Abbildung 1: Sobald Sie den Wert der Variablen »anton« der Variablen »berta« zuweisen, gerät der Compiler ins Stolpern und meldet einen Fehler.

Genauso wie im ersten Listing hat die Variable »anton« als Wert eine Datenstruktur vom Typ »Book« bekommen. Die Variable »anton« ist nach wie vor der Owner des Werts (Regel 1). Als Nächstes hat das Programm der Variablen »berta« den Wert der Variablen »anton« zugewiesen. Gemäß Regel 2 – es kann nur einen Owner geben – ist jetzt die Variable »berta« der Owner. In Rust heißt das: The ownership is moved. Der Begriff “move” kam auch in der Fehlermeldung des Compilers vor. Die Variable »anton« besitzt nach der Zuweisung in letzter Konsequenz keinen definierten Wert mehr, will aber »println!()« darauf zugreifen. Angesichts dessen protestiert der Compiler. In anderen Programmiersprachen wie dem klassischen C akzeptiert er ein solches Vorgehen ohne Fehlermeldung.

Die Konsequenz der Zuweisung in Rust: Zur Laufzeit würde das Programm versuchen, auf den Speicher zuzugreifen, in dem der Wert von »anton« hinterlegt wurde. Da dieser Speicher dann eventuell nicht mehr existiert, wäre ein Absturz des Programms mit einem Speicherfehler die unvermeidliche Folge. Der Rust-Compiler fungiert hier als wichtiger Assistent bei der Speicherverwaltung. Läuft das Kompilieren ohne Fehlermeldung durch, garantiert Rust dafür, dass es keine Speicherfehler gibt. Da diese Prüfungen bereits zur Kompilierzeit erfolgt, muss Rust bei der Ausführung dafür keine Performance mehr abzwacken.

Zugegebenermaßen wirkt das Thema Ownership im ersten Moment etwas gewöhnungsbedürftig. Mir hat dabei geholfen, dass ich mir die Variablen – also die Eigentümer – als Personen vorgestellt habe und deren Werte als Gegenstände. Im zweiten Beispiel hätte die Person Anton das Buch an Berta weitergegeben. Von da an lag das Buch somit bei Berta.

Dieses von Rust verwendete Verfahren bezeichnet man auch als Ownership Based Resource Management oder kurz OBRM.

Basis-Datentypen

Das Programm in Listing 3 ersetzt den Datentyp »Book« durch den Datentyp »i32«, eine Ganzzahl. An diesem Programm hat der Rust-Compiler nichts auszusetzen. Das liegt daran, dass Basistypen wie »i32« in Rust ein besonderes Verhalten aufweisen.

Listing 3

Copy

fn main() {
  let anton = 5;
  let berta = anton;
  println!("{0}", anton);
}

In der Programmzeile, in der die Variable »berta« den Wert von »anton« erhält, kopiert Rust automatisch den Wert. Die Variable »anton« behält den Wert 5, die Variable »berta« bekommt eine Kopie. Somit sind beide jeweils Owner ihrer eigenen 5. Das funktioniert, da die Basistypen den Trait (also die Schnittstelle) »Copy« haben, und erweist sich als sehr praktisch, da das Kopieren einfacher Werte wenig Aufwand bedeutet. Zusätzlich umgeht diese Vorgehensweise viele Probleme rund um Speicherverwaltung und Ownership.

Zu den Basistypen, die Rust aufgrund des Traits »Copy« bei einem anstehenden Move automatisch kopiert, gehören alle Integer- und Float-Datentypen, der boolesche Datentyp (»bool«) sowie der Datentyp »char« für einzelne Zeichen. Hinzu kommen Tupel, die Elemente eines Datentyps enthalten, der über die Copy-Schnittstelle verfügt.

Geklont

Man könnte jetzt auf die Idee kommen, das bei Basistypen angewandte automatische Kopieren einfach auf alle Datentypen auszuweiten. Das zöge jedoch erheblichen Aufwand nach sich.

In unserem Beispiel setzt sich der Datentyp »Book« aus zwei Datentypen zusammen, »String« und »i32«. Der Datentyp »String« besteht aber aus beliebig vielen Zeichen. In einem anderen Fall könnte eine Struktur ein Element haben, das selbst eine Struktur ist, die ihrerseits einen Vektor enthält. Damit zwei unabhängige Kopien entstehen, müsste Rust alle Elemente samt deren Bestandteilen duplizieren, inklusive der jeweiligen Unterbestandteile und so weiter. Eine Automatik ergibt an dieser Stelle keinerlei Sinn. Allerdings besteht die Möglichkeit, eigene Datentypen mit dem Trait »Clone« auszustatten, der dem Trait »Copy« ähnelt (Listing 4).

Listing 4

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();
  println!("{0}", anton.name);
}

Die Datenstruktur bekommt in Zeile 1 mit der Anweisung »derive« die Clone-Schnittstelle zugewiesen. In Zeile 8 ruft das Programm explizit die Methode »clone()« auf. Sie kopiert die in der Variable »anton« gespeicherte Struktur sowie alle enthaltenen Elemente. Der Unterschied zwischen Klonen und Kopieren: Sie müssen »clone« immer explizit aufrufen, Rust bietet dafür keine Automatisierung. Bei komplexeren Datenstrukturen müssen Sie also stets selbst entscheiden, ob Sie im konkreten Fall mit einer Kopie arbeiten wollen und ob das überhaupt sinnvoll ist.

Vorsicht, Falle!

In Listing 5 findet sich eine Funktion »drucke()«, die den als Argument übergebenen Titel eines Buchs ausgibt. Hier wirft der Rust-Compiler eine Fehlermeldung.

Listing 5

Funktionsaufruf

fn drucke(b:Book){
  println!("Buch {0}", b.name);
}
fn main() {
  let anton = Book{name: "Alice im Wunderland".to_string(), pages:120};
  drucke(anton);
  let berta = anton;
}

In der Funktion »main()« wird die Variable »anton« der Owner der erstellten Datenstruktur »Book« (Zeile 5). Danach ruft das Programm die Funktion »drucke()« mit der Variablen »anton« als Argument auf (Zeile 6). An dieser Stelle passiert etwas Entscheidendes: Die Eigentümerschaft geht von der Variablen »anton« auf die Variable »b« über, das Argument der Funktion – und weg ist sie. In Zeile 7 versucht das Programm, der Variablen »berta« den Wert von »anton« zuzuweisen. Das führt zwangsläufig zu einem Fehler, da »anton« nicht mehr der Owner ist.

Die Übergabe eines Werts an eine Funktion entspricht also einer Zuweisung an eine andere Variable. Rust nimmt ein Move (Übertragen der Ownership) oder bei Basistypen ein automatisches Kopieren der Werte vor. Der Return-Wert einer Funktion überträgt ebenfalls die Ownership. Die Funktion »drucken()« könnte beispielsweise den Wert »b« als Return-Wert zurückgeben, den das Programm dann der Variablen »anton« zuweist. Dementsprechend wäre danach »anton« wieder der Owner. Ein solches Vorgehen wirkt etwas bemüht.

Borrowing

Rust erlaubt es dem Owner eines Werts, diesen mithilfe von Referenzen zu verleihen, ohne dabei seinen Besitz aufzugeben. Eine Referenz verweist auf einen Wert, und Rust sorgt dafür, dass sie immer auf den korrekten Wert zeigt, solange sie existiert.

In Listing 6 verwendet die Funktion statt des Datentyps selbst eine Referenz auf den Datentyp »Book« als Argument. Dadurch bleibt die Ownership bei der Variablen »anton«, und das Programm kompiliert ohne Fehlermeldung. Eine Referenz auf einen bestimmten Datentyp drücken Sie durch ein kaufmännisches Und (»&«) vor dem Datentyp aus (Zeile 1).

Listing 6

Referenzen

fn drucke(b:&Book){
  println!("Buch {0}", b.name);
}
fn main() {
  let anton = Book{name: "Alice im Wunderland".to_string(), pages:120};
  drucke(&anton);
  let berta = anton;
}

Um von der Referenz auf den Inhalt zu kommen, auf den sie verweist, gibt es den Operator »*« (Dereferencing): »let s = (*b).pages«. Über »*b« greift das Programm auf den Datentyp »Book« zu, auf den die Referenz »b« deutet. Da dieser Datentyp über ein Attribut »pages« (»i32«) verfügt, bekommt die Variable »s« eine Kopie.

Aufmerksame Leserinnen und Leser dürften bemerkt haben, dass das Programm aus Listing 6 in der Zeile 2 auf »b.name« zugreift – ohne den Operator »*«. Dahinter steckt ein Entgegenkommen des Rust-Compilers: Steht vor einem ».« eine Referenz, ergänzt der Compiler automatisch den Operator »*«. Das trägt zur Übersicht bei, und Sie sparen sich viele Klammern und Sternchen. Auf einen Wert können Sie beliebig viele Referenzen vornehmen – allerdings nur, wenn es um Werte geht, die sich nicht ändern lassen.

Veränderbare Daten

Die bisherigen Beispielprogramme haben jeweils einen einzelnen Wert des Datentyps »Book« erstellt und der Variablen »anton« zugewiesen. Ein Ändern war grundsätzlich nicht möglich: Rust sieht alle neuen Werte prinzipiell als unveränderlich an, es sei denn, Sie verlangen explizit das Gegenteil.

In Listing 7 kann das Programm durch das Hinzufügen der Angabe »mut« in der ersten Zeile den Wert von »anton« anpassen. Die Funktion »drucke()« in Zeile 8 erwartet in dieser Version eine Referenz auf einen modifizierbaren Wert: Hier erscheint die Angabe »&mut«, was für eine Referenz auf einen änderbaren Wert steht (Mutable Reference). Beim Aufruf von »drucke()« muss vor die Variable »anton« noch einmal die Angabe »mut«, damit die Funktion etwas am Wert ändern kann.

Listing 7

Veränderbare Werte

fn drucke(b:&mut Book){
    println!("Buch {0}", b.name);
    b.pages = 400;
}
fn main() {
    let mut anton = Book{name: "Alice im Wunderland".to_string(), pages:120};
    drucke(&mut anton);
    let berta = anton;
}

Von diesen Mutable References funktioniert zu einem Zeitpunkt grundsätzlich nur eine. Um das Prüfen und Garantieren kümmert sich hier wieder der Rust-Compiler, konkret dessen Borrow Checker. Darüber hinaus kennt die Programmiersprache zwei Regeln zu Referenzen. Die erste besagt, dass zu einem bestimmten Zeitpunkt entweder genau eine Referenz auf einen veränderbaren Wert (Mutable Reference) vorkommen darf oder beliebig viele auf nicht änderbare Werte (Immutable References). Laut der zweiten Regel verweisen Referenzen immer auf einen definierten Wert.

Fazit und Ausblick

Am Anfang wirken die Spielregeln von Rust in Bezug auf die Speicherverwaltung etwas gewöhnungsbedürftig. Sie führen jedoch dazu, dass Sie über die Zeit stetig bessere und speichereffizientere Programme schreiben. In der nächsten Folge von Planet Rust setze ich mich mit der Speicherbelegung der Programmiersprache auseinander und thematisiere die unterschiedlichen Datentypen für Zeiger.

Ich hoffe, meine Kollegin Clara findet den Artikel verständlich genug, damit ich zu meinem kostenlosen Mittagessen komme. Zum Schluss lege ich Ihnen noch die zwei Videos “Rust: Hack Without Fear!” [1] und “Rust for Curious Developers” [2] ans Herz, mit denen Sie tiefer in das Thema Speicherverwaltung eintauchen können. (csi)

Infos

  1. “Rust: Hack Without Fear!”: https://www.youtube.com/watch?v=lO1z-7cuRYI
  2. “Rust for Curious Developers – Memory Management, Zero-Cost Abstractions and More”: https://www.youtube.com/watch?v=lDYRwDwAmzQ
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben