Zum Inhalt springen

Nebenläufigkeit in Rust

Nebenläufigkeit (Concurrency) heißt, dass mehrere Aufgaben gleichzeitig laufen. Genau hier werden viele Sprachen richtig gefährlich: Wenn zwei Threads zur selben Zeit auf dieselben Daten zugreifen, können sie diese Daten beschädigen. Solche Fehler sind extrem mühsam zu finden. Die Borrowing-Regeln (Ausleihen) wurden genau gegen diese Art von Fehlern entworfen – und dieser Schutz gilt direkt auch für Threads.

use std::thread;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("Thread: {i}");
}
});
for i in 1..=3 {
println!("main: {i}");
}
handle.join().unwrap(); // warten, bis der Thread fertig ist
}
▶ Selbst ausprobieren

thread::spawn nimmt ein Closure und führt es in einem neuen Thread aus, parallel zu allem anderen. .join() wartet, bis dieser Thread fertig ist. Ohne .join() könnte main schon beendet sein, bevor der neue Thread überhaupt drankommt.

Ein Closure, das du an thread::spawn übergibst, muss alle Daten, die es benutzt, vollständig besitzen. Es könnte ja noch laufen, wenn der ursprüngliche Gültigkeitsbereich (Scope) schon zu Ende ist. Mit dem Schlüsselwort move erzwingst du das:

use std::thread;
fn main() {
let message = String::from("Hallo aus einem Thread");
let handle = thread::spawn(move || {
println!("{message}");
});
handle.join().unwrap();
}

Ausgabe:

Hallo aus einem Thread
▶ Selbst ausprobieren

Ohne move kompiliert Rust diesen Code nicht. Der Compiler kann nicht garantieren, dass message noch gültig ist, wenn der neue Thread sie tatsächlich benutzt. move überträgt die Ownership von message in das Closure. Das ist derselbe „Move“, den du auf der Seite Ownership gesehen hast – nur dass der Wert diesmal an einen Thread geht statt an eine andere Variable.

Normale Referenzen funktionieren nicht über Threads hinweg, denn sie dürfen den Scope, in dem sie entstanden sind, nicht überleben. Um Daten zwischen Threads zu teilen, kombinierst du deshalb zwei Dinge, die du schon kennst: Arc (von der Seite Smart Pointer) für geteilte Ownership und einen Mutex, der garantiert, dass immer nur ein Thread gleichzeitig an die Daten kommt:

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Ergebnis: {}", *counter.lock().unwrap());
}

Ausgabe:

Ergebnis: 5
▶ Selbst ausprobieren

counter.lock() wartet, bis es sicher exklusiven Zugriff auf den Wert bekommt. Dann passiert die Änderung, und danach wird die Sperre automatisch wieder freigegeben. Fünf Threads erhöhen denselben Zähler, und dank des Mutex kommen sie sich nie in die Quere. Du bekommst zuverlässig 5 – und keine kleinere Zahl, weil eine Änderung verloren gegangen ist.

Geteilte Daten zu sperren ist eine Art, wie Threads zusammenarbeiten können. Der andere übliche Weg: gar nichts teilen und die Threads stattdessen Nachrichten aneinander schicken lassen. Darum geht es auf der Seite Channels.

In den meisten Sprachen hindert dich nichts daran, das Sperren eines Mutex zu vergessen oder versehentlich eine Referenz zwischen Threads zu teilen, die nicht geteilt werden darf. Bei Rust setzt der Compiler das tatsächlich durch: Code, der zu einem Data Race (gleichzeitiger, ungeschützter Zugriff auf dieselben Daten) zwischen Threads führen würde, kompiliert einfach nicht. Das ist dasselbe Ownership- und Borrowing-System, das du in diesem Kurs schon gelernt hast – nur angewendet auf Threads statt auf einen einzelnen Scope. Daher der Name „furchtlose Nebenläufigkeit“.

Zähl die Zahlen von 1 bis 100 mit zwei Threads zusammen: Einer summiert 1 bis 50, der andere 51 bis 100. Danach addiert main die beiden Ergebnisse. Tipp: Das Closure eines Threads kann einen Wert zurückgeben, und handle.join().unwrap() gibt ihn dir zurück.

Lösung anzeigen
use std::thread;
fn main() {
let first = thread::spawn(|| {
let total: u32 = (1..=50).sum();
total
});
let second = thread::spawn(|| {
let total: u32 = (51..=100).sum();
total
});
let a = first.join().unwrap();
let b = second.join().unwrap();
println!("{a} + {b} = {}", a + b);
}

Ausgabe:

1275 + 3775 = 5050
▶ Selbst ausprobieren

Diese Closures benutzen keine Variablen von außen, deshalb brauchen sie kein move. Beide Threads laufen gleichzeitig, und join() wartet auf jeden einzelnen und gibt dir sein Ergebnis zurück.

Starte drei Threads. Jeder fügt einem gemeinsamen Vec<String> eine Zeile wie "Thread 2 war hier" hinzu. Wenn alle Threads fertig sind, gib die Zeilen sortiert aus. Tipp: Nimm Arc<Mutex<Vec<String>>> wie im Beispiel mit dem Zähler und sortiere am Ende eine Kopie – die Threads können nämlich in beliebiger Reihenfolge fertig werden.

Lösung anzeigen
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let book = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
for id in 1..=3 {
let book = Arc::clone(&book);
handles.push(thread::spawn(move || {
book.lock().unwrap().push(format!("Thread {id} war hier"));
}));
}
for handle in handles {
handle.join().unwrap();
}
let mut lines = book.lock().unwrap().clone();
lines.sort();
for line in lines {
println!("{line}");
}
}

Ausgabe:

Thread 1 war hier
Thread 2 war hier
Thread 3 war hier
▶ Selbst ausprobieren

Lösch die Zeile lines.sort() und führ das Programm ein paarmal aus: Die Reihenfolge kann sich ändern. Erst durch das Sortieren bekommst du jedes Mal dieselbe Ausgabe.

  • thread::spawn(closure) führt ein Closure in einem neuen Thread aus, .join() wartet, bis er fertig ist.
  • Ein Closure für einen Thread braucht meistens move, damit es die Daten, die es benutzt, vollständig besitzt.
  • Daten teilst du zwischen Threads mit Arc (geteilte Ownership) zusammen mit Mutex (exklusiver Zugriff). counter.lock() gibt dir sicheren Zugriff, immer nur für einen Thread auf einmal.
  • Der Compiler lehnt Code, der zu einem Data Race führen würde, schon beim Kompilieren ab – nicht erst zur Laufzeit.

Kurzer Check

1. Warum braucht ein Closure für thread::spawn meistens das Schlüsselwort move?

2. Wofür benutzt man Arc<Mutex<T>>?

3. Was kann schiefgehen, wenn du .join() bei einem Thread-Handle vergisst?

4. Warum kann sich die Reihenfolge der Zeilen mit „main:“ und „Thread:“ zwischen zwei Läufen desselben Programms ändern?

Punkte: 0 / 4