Rust als sichere Programmiersprache für systemnahe und parallele Software

Seite 3: Mutexe

Inhaltsverzeichnis

Eine Möglichkeit, den Code fehlerfrei zu implementieren, sind Mutexes. Ein Mutex in Rust nimmt ähnlich wie ein Mutex in C++ die zu schützende Variable auf und synchronisiert alle Zugriffe auf diese Variable. Der folgende Quellcode zeigt ein einfaches Beispiel, wie sich ein Mutex nutzen lässt.

static readonly_number: u64 = 42;
static counter: Mutex<u64> = Mutex::new(0);

pub fn init() {
let guard = counter.lock().unwrap();
guard = readonly_number;
}

In der Funktion init erfolgt der Versuch, über die Funktion lock() Zugriff auf die Variable zu bekommen, die der Mutex schützt. Sollte dabei kein Fehler ausgelöst werden, blockiert die Funktion lock() so lange, bis der aufrufende Thread exklusiven Zugriff auf diese Variable hat. Der Thread gibt die geschützte Variable automatisch wieder frei, sobald die entsprechende Reference (im Beispiel guard) zerstört ist.

Mutexes lösen das Problem der Wettlaufsituation der Variable sum aus dem Pi-Beispiel, allerdings ist dabei sicherzustellen, dass der Mutex mindestens so lange lebt wie die entsprechenden Threads. Eine Möglichkeit, das Lebenszeitproblem von sum zu lösen, ist, explizit einen global veränderlichen Mutex anzulegen. Dies schränkt allerdings die Struktur des Codes deutlich ein, da zum Beispiel jeder Versuch Pi zu berechnen neue Threads erzeugt, die alle denselben Mutex (und entsprechend dieselbe Summe) benutzen. Eine Alternative dazu sind Rc beziehungsweise der Thread-sichere Gegenpart Arc, die es ermöglichen Variablen auf dem Heap anzulegen. Rust benutzt Referenzzählung um sicherzustellen, dass die über Rc und Arc angelegten Variablen so lange existieren, bis keine Referenz auf die entsprechende Variable im Programm existiert.

Um die Zahl Pi berechnen zu können, gilt es, die Wettlaufsitutation aus dem ursprünglichen Beispiel zu vermeiden. Es liegt daher nah, die Variable sum durch ein Mutex zu schützen. Allerdings kann auch das Mutex durch den Thread nur einmal länger existieren als die Funktion, die es erzeugt hat. Aus diesem Grund kommt ein Arc zur Verwaltung des Mutex zum Einsatz. Jeder Thread erhält durch die clone-Funktion einen eignen Arc, die alle auf denselben Mutex zeigen. Der Mutex wird erst wieder freigegeben, wenn keine Referenz auf dieses Objekt existiert. Im Beispiel bedeutet das, dass alle Threads beendet sind und die Referenz wieder freigibt.

let sum = Arc::new(Mutex::new(0.0 as f64));

let threads: Vec<_> = (0..nthreads).map(|tid| {
let sum = sum.clone();

thread::spawn(move || {
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.lock().unwrap() += 4.0 / (1.0 + x * x);
}
})
}).collect();

Das Anfordern eines Schreibzugriffs ist bei Mutexes eine recht zeitaufwändige Funktion. Insbesondere in vorliegenden Beispiel ist die Verwendung eines Mutex eine schlechte Wahl, da der synchronisierte Zugriff im Verhältnis zur restlichen Berechnung recht umfangreich ausfällt. Effizienter wäre die Berechnung von Teilergebnissen, die sich später zum Gesamtergebnis zusammenführen lassen.

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

let threads: Vec<_> = (0..nthreads)
.map(|tid| {
thread::spawn(move || {
let mut partial_sum = 0 as f64;
for i in start..end {
let x = (i as f64 + 0.5) * step;
partial_sum += 4.0 / (1.0 + x * x);
}
partial_sum
})}).collect();

In diesem Beispiel berechnet jeder Thread seine Teilsumme (partial_sum) lokal und liefert sie beim Beenden als Ergebnis zurück. Einen Rust-Einsteiger dürfte an dieser Stelle verwundern, dass das Teilergebnis nicht per return-Anweisung kommt. Die Variable ohne abschließendes Semikolon am Schluss eines Blocks stellt hierfür die verkürzte Schreibweise dar. Die Ergebnisse sind im obigen Beispiel in einer Map abgelegt und mit der Methode collect aufsummiert. Diese Vorgehensweise macht die Verwendung von Mutexes überflüssig und garantiert darüber hinaus eine bessere Skalierbarkeit.