Rust als sichere Programmiersprache für systemnahe und parallele Software

Seite 2: Ownership/Borrowing

Inhaltsverzeichnis

Wie bereits erwähnt, bietet Rust ein sicheres Speichermodell an, das auf Garbage Collection (GC) verzichtet. GC versucht üblicherweise zur Laufzeit nicht länger benötigte Speicherbereiche zu identifizieren und freizugeben. Bei Rust wird dies bereits zur Kompilierzeit bestimmt. Voraussetzung dafür ist das Konzept des Ownerships.

std::vector<std::string>* x = nullptr;

{
std::vector<std::string> z;

z.push_back("Hello World!");
x = &z;
}

std::cout << (*x)[0] << std::endl;

Das einfache Beispiel erläutert das Konzept des Ownerships und macht die Unterschiede zu C/C++ deutlich. Das Beispiel ist zwar konstruiert, zeigt aber dennoch, dass es möglich ist, gültigen C++-Code zu schreiben, den der Compiler anstandslos kompiliert, obwohl er ungültige Speicherzugriffe enthält. Der Vektor z wird nach Verlassen des Code-Blocks zwischen den geschweiften Klammern gelöscht, obwohl noch die Referenz x auf diesen existiert.

let x;

{
let z = vec!("Hello World!");

x = &z;
}

println!("{}", x[0]);

Natürlich lässt sich ein entsprechender Fehler – wie das Beispiel zeigt – auch in Rust programmieren. Im Unterschied zu einem C/C++-Compiler übersetzt ein Rust-Compiler diesen Code allerdings nicht. Er meldet stattdessen den Fehler, dass z nicht lang genug lebt, und akzeptiert das obige Programm nicht als gültig, obwohl es syntaktisch korrekt ist.

Um diese Schutzmechanismen zu gewährleisten, führt Rust das Konzept des Ownerships (Besitzer) sowie des Borrowings (Ausleihen) ein. In Rust gibt es immer nur einen Besitzer, der das Recht hat, das Objekt freizugeben. Erst durch das Erstellen von Referenzen lässt sich das Objekt verleihen.

let mut x = vec!("Hello World!");

{
let z = &mut x;
// Do something with z...
}

println!("{}", x[0]);

Durch das Erstellen der Referenz auf x steht das Objekt zum Ausleihen bereit. Das Schlüsselwort mut beschreibt in diesem Zusammenhang, dass die Variable veränderbar ist. Im Unterschied zu anderen Programmiersprachen sind in Rust per Default alle Variablen konstant. Wichtiger ist aber auch die Feststellung, dass die geschweiften Klammern in diesem Beispiel essenziell sind. Ohne sie verweigert der Compiler in der letzten Zeile das Ausleihen des Vektors an println, da nicht x, sondern z der Besitzer des Vektors ist. Es gilt die Grundregel, dass nur der aktuelle Besitzer etwas verleihen darf.

Das Ownership-/Borrowing-Konzept ist gewöhnungsbedürftig und erfordert ein Umdenken. So stellt eine doppelte verkettete Liste, die per se mehrere Besitzer kennt, in Rust eine kleine Herausforderung dar. Allerdings bietet das Konzept auch deutliche Vorteile. Die Speicherverwaltung ist sicher und nebenläufiger Code lässt sich bei nur einem Besitzer einfach realisieren.

Um zu veranschaulichen, wie sich mit Rust nebenläufiger Code erstellen lässt, wird die Zahl Pi parallel bestimmt. Im Prinzip geht es darum, das Integral von \$f(x) = 4 / (1+x^2)\$ zwischen 0 und 1 zu bestimmen. Eine klassische Annäherung stellt das Zeichnen von Rechtecken unterhalb der Funktion f dar. Die Flächeninhalte sind einfach zu bestimmen, und je schmaler die Rechtecke ausfallen, umso genauer ist das Ergebnis.

Schematische Darstellung der Pi-Approximation.

In Sprachen wie C/C++ oder auch Java ist es relativ einfach fehlerhaften Code mit Wettlaufsituationen zu erzeugen. Der folgende Beispielcode versucht Pi mit Threads zu berechnen. Er beinhaltet allerdings eine Wettlaufsituation bezüglich der Variable sum und je nach konkreter Ausführung des Programms auf der CPU liefert diese das falsche oder korrekte Ergebnis zurück.

const double step = 1.0 / NUM_STEPS;
double sum = 0.0;

for (int tid = 0; tid < nthreads; ++tid) {
std::thread t([&](int start, int end){

for (int i = start; i < end; i++) {
double x = (i + 0.5) * step;
sum += 4.0 / (1.0 + x * x);
}
}, (NUM_STEPS / nthreads) * tid
, (NUM_STEPS / nthreads) * (tid + 1));
}

Portiert man diesen Code direkt nach Rust, sieht er ähnlich wie der im folgenden Beispiel aus. Der Rust-Compiler übersetzt diesen Code aber nicht, sondern meldet vielmehr verschiedene Fehler.

let step = 1.0 / NUM_STEPS as f64;
let mut sum = 0.0 as f64;

let threads: Vec<_> = (0..nthreads)
.map(|tid| {
thread::spawn(|| {
let start = (NUM_STEPS / nthreads) * tid;
let end = (NUM_STEPS / nthreads) * (tid+1);

for i in start..end {
let x = (i as f64 + 0.5) * step;
sum += 4.0 / (1.0 + x * x);
}
})
}).collect();

In Rust dürfen Threads nur mit Variablen arbeiten, deren Laufzeit mindestens so lange ist wie die des Threads, in dem sie genutzt werden. Für die Threads der Rust-Standardbibliothek ergibt sich dadurch, dass die Variable entweder dem Thread selbst gehören muss oder aber die Variable static lifetime besitzt, also über die gesamte Laufzeit des Programms hinweg existiert. Diese Regel verhindert, dass ein Thread auf Variablen zugreift, die zwar zum Start des Threads existiert haben, anschließend aber gelöscht wurden, bevor der Thread beendet war. Die Variablen step und sum verletzen diese Regel, da sie auf dem Stack angelegt wurden – die erzeugten Threads können länger leben, als die Funktion, die sie erzeugt hat.

Für die Variable step lässt sich dieses Problem einfach lösen, da sie eine unveränderliche Variable ist und jeder Thread eine Kopie dieser Variablen bekommen kann. Rust benutzt dafür das Schlüsselwort move (thread::spawn(move ||…), das dem neu erzeugten Thread eine unveränderliche Kopie aller Variablen mitgibt, auf die im Thread zugegriffen wird. Im Unterschied zur unveränderlichen Variablen step verändern die erzeugten Threads sum, sodass sich in deren Fall auch durch das Hinzufügen des Schlüsselworts move nicht alle Compilerfehler auflösen lassen.