Rust als sichere Programmiersprache für systemnahe und parallele Software

Datenparallelität mit Rayon

Bisher kamen allein Standardkomponenten von Rust zum Einsatz, um nebenläufigen oder parallelen Code zu schreiben. Softwareentwicklern kommt dabei die Verantwortung zu, beispielsweise die Verteilung der Last über die Threads selbst durchzuführen. In C/C++ ließe sich dieser Schritt zum Beispiel durch OpenMP vereinfachen. Mit Rayon existiert eine Bibliothek für Rust, die vergleichbare Ziele verfolgt und sowohl Task- als auch Datenparallelität unterstützt.

let step = 1.0 / NUM_STEPS as f64;

let sum: f64 = (0..NUM_STEPS).into_par_iter()
.map(|i| {
let x = (i as f64 + 0.5) * step;
4.0 / (1.0 + x * x)
}).sum();

Die Funktion into_par_iter durchläuft parallel eine Schleife von 0 bis NUM_STEPS. Für die Berechnung automatisch erzeugte Threads bekommen einen Teil von der Schleife zugewiesen. Wie groß diese Teilbereiche sind, entscheidet Rayon eigenständig. Wie bei OpenMP haben Softwareentwickler aber die Freiheit, die Zuweisung zu beeinflussen oder sie komplett der Bibliothek zu überlassen.

Nachrichtenaustausch als Parallelisierungskonzept

Im Allgemeinen gilt die Parallelisierung über den gemeinsamen Speicher als fehleranfällig. Zudem lässt sich die Korrektheit einer solchen Vorgehensweise schwierig belegen. Wie gezeigt, bietet Rust Anwendungsmöglichkeiten, um diese Probleme zu minimieren. Darüber hinaus bietet sich aber auch ein Mechanismus an, der quasi Communicating Sequential Processes (CSP) für Rust darstellt. Die Grundlagen von CSP hat Tony Hoare bereits im Jahr 1978 veröffentlicht. Sie stellen eine Prozessalgebra zur Beschreibung von Interaktion zwischen kommunizierenden Prozessen dar. CSP eignet sich hervorragend, um nebenläufige Anwendungen zu spezifizieren und zu verifizieren. Vereinfacht gesagt, stellt CSP das theoretische Modell für die Parallelisierung mit Nachrichten dar und bildet somit die Grundlage für das Message Passing Interface (MPI).

let (tx, rx) = mpsc::channel();
let mut sum = 0.0;

for id in 0..nthreads {
// The sender endpoint can be copied
let thread_tx = tx.clone();
let start = (NUM_STEPS / nthreads as u64) * id;
let end = (NUM_STEPS / nthreads as u64) * (id+1);

// Each thread will send its partial sum via the channel
thread::spawn(move || {
let partial_sum = /* calculate the surface area */
thread_tx.send(partial_sum).unwrap();
});
};

for _ in 0..nthreads {
// The `recv` method picks a message from the channel
// `recv` will block the current thread if there no messages available
sum = sum + rx.recv().unwrap();
}

Das Beispiel veranschaulicht den Aufbau von Kanälen zwischen den einzelnen Threads. Jeder Thread sendet seine Teilergebnisse zum Hauptthread, der diese einsammelt und aufsummiert. Die Problematik einer Wettlaufsituation lässt sich damit vollständig vermeiden.