Praxisteil & Übungen: Nebenläufigkeit und asynchrone Programmierung
Herzlich willkommen zum Praxisteil über Nebenläufigkeit und Parallelität! In der modernen Softwareentwicklung sind Mehrkernprozessoren der Standard. Um das volle Potenzial einer CPU auszuschöpfen, müssen wir unsere Programme so schreiben, dass Aufgaben gleichzeitig ausgeführt werden können.
Rust geht hier einen einzigartigen Weg, der als “Fearless Concurrency” (Furchtlose Nebenläufigkeit) bekannt ist. Dank des strengen Typsystems und der Ownership-Regeln verhindert der Rust-Compiler die gefürchteten Data Races (Daten-Wettläufe) bereits zur Compilezeit. In diesem Praxisteil werden wir einen parallelen Web-Scraper simulieren, der mehrere Webseiten gleichzeitig abfragt und die Ergebnisse sicher in einer gemeinsamen Datenstruktur sammelt.
1. Praxis-Szenario: Ein paralleler Web-Scraper und Status-Prüfer
Stellen wir uns vor, wir betreiben ein Monitoring-Tool, das die Erreichbarkeit von verschiedenen Kunden-Webseiten überwacht. Wir haben eine Liste von URLs (z. B. 10 verschiedene Adressen). Wenn wir diese nacheinander (sequentiell) abfragen würden und jede Anfrage 1 Sekunde dauert, würde das gesamte Programm 10 Sekunden lang blockieren.
Unsere Aufgabe ist es, einen Status-Prüfer zu entwickeln, der:
- Für jede URL einen eigenen Betriebssystem-Thread spawnt (
std::thread::spawn). - Die Netzwerkanfrage simuliert (durch ein künstliches Schlafenlegen des Threads mit
std::thread::sleep). - Die Antwortzeit und das Ergebnis (z. B. “200 OK” oder “404 Not Found”) in einer gemeinsamen Ergebnisliste speichert.
- Sicherstellt, dass kein Thread ungeordnet auf den Speicher zugreift, um Abstürze oder fehlerhafte Daten zu vermeiden.
Die Übungsaufgabe befindet sich im Verzeichnis:
- exercises/16_concurrency/src/main.rs (Vervollständigen Sie dort die Thread-Steuerung und die Datenfreigabe)
2. Didaktische Alltagsanalogie: Küche, Köche und der Sprechstein
Bevor wir in den Code einsteigen, klären wir den Unterschied zwischen zwei Begriffen, die oft verwechselt werden: Nebenläufigkeit (Concurrency) und Parallelität (Parallelism).
Parallelität (Mehrere Köche in der Küche)
Stellen wir uns eine Restaurantküche vor. Wir haben drei Köche (unsere CPU-Kerne). Koch A schneidet Zwiebeln, Koch B brät das Fleisch und Koch C bereitet die Soße zu. Alle arbeiten gleichzeitig (parallel) an ihren eigenen Aufgaben. Das ist Parallelität.
Nebenläufigkeit (Ein Koch, viele Aufgaben)
Nun stellen wir uns vor, wir haben nur einen einzigen Koch. Dieser Koch schiebt einen Kuchen in den Ofen. Der Kuchen muss 40 Minuten backen. Anstatt 40 Minuten unbeschäftigt vor dem Ofen zu stehen, nutzt der Koch die Wartezeit: Er wäscht das Geschirr ab und schneidet Gemüse. Sobald der Ofen klingelt, kehrt er zum Kuchen zurück. Er arbeitet die Aufgaben nicht parallel ab, sondern wechselt geschickt hin und her, um Leerlaufzeiten zu vermeiden. Das ist Nebenläufigkeit (wie sie in Rust mit async/await umgesetzt wird).
Die Absprache: Mutex (Der Sprechstein)
Wenn unsere drei Köche nun alle in dasselbe Rezeptbuch schreiben wollen (gemeinsame Datenstruktur), gibt es Chaos, wenn zwei gleichzeitig denselben Stift ansetzen. Die Lösung: Ein Mutex (Abkürzung für Mutual Exclusion, gegenseitiger Ausschluss). Ein Mutex funktioniert wie ein “Sprechstein” in einer Diskussionsrunde. Nur der Koch, der den Stein in den Händen hält, darf in das Buch schreiben. Alle anderen Köche müssen warten, bis der Stein wieder auf den Tisch gelegt wird.
3. Strukturierte Praxis-Einheiten
3.1 Get Started: Threads spawnen mit std::thread::spawn
In Rust erstellen wir einen neuen Betriebssystem-Thread mit der Funktion std::thread::spawn. Diese Funktion nimmt eine Closure entgegen, die den Code enthält, den der Thread ausführen soll.
Beispiel:
#![allow(unused)]
fn main() {
use std::thread;
use std::time::Duration;
let handle = thread::spawn(|| {
// Dieser Code läuft in einem separaten Thread
thread::sleep(Duration::from_millis(500)); // Simuliert Arbeit
println!("Thread fertig!");
});
// Der Haupt-Thread läuft hier sofort weiter, ohne zu warten!
println!("Haupt-Thread läuft...");
// Warten, bis der Thread fertig ist
handle.join().unwrap();
}
Erklärung:
thread::spawn: Startet den Thread. Er läuft sofort im Hintergrund los.Duration::from_millis: Gibt ein Zeitintervall an.handle.join(): Der Aufruf blockiert den aktuellen Thread (in diesem Fall den Haupt-Thread), bis der gestartete Thread seine Arbeit beendet hat. Das verhindert, dass das Programm beendet wird, bevor der Hintergrund-Thread seine Ausgabe machen kann.
Aufgabe: Schreiben Sie ein einfaches Programm, das für drei URLs jeweils einen Thread startet, der den Text “Prüfe URL…” ausgibt.
3.2 Die Lifetime-Hürde: move-Closures und das Überleben von Variablen
Wenn wir aus einem Thread heraus auf Variablen der Umgebung zugreifen wollen, stoßen wir auf eine strenge Barriere des Compilers.
Der CDD-Ansatz: Wir provozieren den Lifetime-Fehler
Versuchen wir, eine URL aus einer lokalen Liste an den Thread zu übergeben:
#![allow(unused)]
fn main() {
let url = String::from("https://rust-lang.org");
let handle = thread::spawn(|| {
// Fehlerquelle: Wir greifen auf die lokale Variable `url` zu
println!("Lese Daten von: {}", url);
});
handle.join().unwrap();
}
Wenn wir diesen Code prüfen, verweigert Rust die Kompilierung mit einer sehr klaren Fehlermeldung:
error[E0373]: closure may outlive the current function, but it borrows `url`, which is owned by the current function
--> src/main.rs:5:32
|
5 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `url`
6 | println!("Lese Daten von: {}", url);
| --- `url` is borrowed here
|
note: function requires argument type to outlive `'static`
Warum ist der Compiler so misstrauisch?
Der Compiler sagt uns: “Du leihst mir url aus. Aber der Thread, den du erstellst, läuft unabhängig von der aktuellen Funktion. Was ist, wenn die Funktion sofort beendet wird, url aus dem Speicher gelöscht wird, der Thread aber noch im Hintergrund läuft und versucht, auf den gelöschten Speicher zuzugreifen?”
Da ein Thread eine beliebige Lebensdauer haben kann, fordert Rust, dass alle Daten, die an den Thread übergeben werden, die Lebensdauer 'static erfüllen müssen – sie müssen dem Thread also komplett gehören oder dauerhaft gültig sein.
Die Behebung: move
Wir müssen den Besitz der Variablen explizit an den Thread übergeben. Das machen wir mit dem Schlüsselwort move vor den vertikalen Strichen der Closure:
#![allow(unused)]
fn main() {
let url = String::from("https://rust-lang.org");
// `move` zwingt die Closure, `url` vollständig in den Thread hineinzuziehen
let handle = thread::spawn(move || {
println!("Lese Daten von: {}", url);
}); // `url` wird hier am Ende des Threads gelöscht!
handle.join().unwrap();
}
Aufgabe:
Nutzen Sie move, um die URLs in Ihre Threads zu verschieben, und beheben Sie so den Lifetime-Fehler.
3.3 Daten teilen über Thread-Grenzen hinweg: Arc und Mutex
Was tun wir, wenn wir die Ergebnisse aller Threads in einer gemeinsamen Liste sammeln wollen? Wir können die Liste nicht einfach mit move in den ersten Thread verschieben, da sie dann für alle nachfolgenden Threads verloren wäre.
Wir benötigen zwei Werkzeuge, die Hand in Hand arbeiten:
Arc<T>(Atomically Reference Counted): Ein Zeiger, der es uns erlaubt, mehrere Besitzer für dieselbe Ressource zu haben. Wenn wir einArcklonen, klonen wir nicht die Daten, sondern erhöhen nur einen Zähler im Speicher. Erst wenn der Zähler auf 0 fällt, werden die Daten gelöscht.Mutex<T>(Mutual Exclusion): Bietet die Garantie, dass immer nur ein Thread die Daten verändern darf.
Beispiel:
#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
use std::thread;
// Wir verpacken einen Vektor in einen Mutex (für veränderlichen Zugriff)
// und diesen wiederum in ein Arc (für geteilten Besitz)
let ergebnisse = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
for i in 0..3 {
// Wir erstellen einen neuen Klon des Arcs für diesen Thread
let ergebnisse_klon = Arc::clone(&ergebnisse);
let handle = thread::spawn(move || {
// 1. Sperre erwerben (lock)
// 2. Wert in den geschützten Vektor schreiben
let mut guard = ergebnisse_klon.lock().unwrap();
guard.push(format!("Ergebnis aus Thread {}", i));
}); // Hier wird `guard` automatisch zerstört und der Mutex wieder freigegeben!
handles.push(handle);
}
// Auf alle Threads warten
for handle in handles {
handle.join().unwrap();
}
// Daten sicher auslesen
println!("Ergebnisse: {:?}", ergebnisse.lock().unwrap());
}
Erklärung:
Arc::clone(&ergebnisse): Erstellt eine neue Referenz auf den Mutex. Jede Thread-Closure erhält ihren eigenenergebnisse_klon..lock().unwrap(): Fordert die Sperre an. Wenn ein anderer Thread den Mutex gerade gesperrt hat, blockiert dieser Aufruf so lange, bis der Mutex frei wird. Dasunwrap()fängt den Fall ab, dass ein anderer Thread mit der Sperre abgestürzt ist (ein sogenannter poisoned Mutex).- RAII-Prinzip (Resource Acquisition Is Initialization): Rust gibt die Sperre automatisch wieder frei, sobald die Variable
guardihren Gültigkeitsbereich verlässt (am Ende der Thread-Closure). Wir müssen kein manuellesunlock()aufrufen!
Aufgabe:
Implementieren Sie die Ergebnisliste für Ihren Scraper unter Verwendung von Arc und Mutex.
4. Genaue Code-Erklärung der Musterlösung
Der vollständige und kompilierbare Code der Musterlösung befindet sich unter solutions/16_concurrency/src/main.rs.
1: // Musterlösung zu Übung 16: Paralleler Web-Scraper (Simulation)
2: // Zeigt den sicheren Einsatz von Threads, Arc, Mutex und Timeouts.
3:
4: use std::sync::{Arc, Mutex};
5: use std::thread;
6: use std::time::{Duration, Instant};
7:
8: // Struktur für das Ergebnis einer URL-Prüfung
9: #[derive(Debug)]
10: pub struct ScrapeResult {
11: pub url: String,
12: pub status: String,
13: pub dauer: Duration,
14: }
15:
16: // Hauptfunktion des Scrapers
17: pub fn run_scraper(urls: Vec<String>) -> Vec<ScrapeResult> {
18: // Wir teilen die Ergebnisliste sicher über Thread-Grenzen
19: let ergebnisse = Arc::new(Mutex::new(Vec::new()));
20: let mut handles = vec![];
21:
22: for url in urls {
23: // Wir klonen das Arc für den Thread
24: let ergebnisse_klon = Arc::clone(&ergebnisse);
25:
26: // Thread starten
27: let handle = thread::spawn(move || {
28: let start = Instant::now();
29:
30: // Simuliere Netzwerk-Latenz (z.B. zwischen 100 und 500 Millisekunden)
31: let laenge = (url.len() % 5 + 1) * 100;
32: thread::sleep(Duration::from_millis(laenge as u64));
33:
34: let dauer = start.elapsed();
35:
36: // Simuliere Statuscode
37: let status = if url.contains("error") {
38: String::from("500 Internal Server Error")
39: } else {
40: String::from("200 OK")
41: };
42:
43: // Ergebnis erstellen
44: let ergebnis = ScrapeResult {
45: url,
46: status,
47: dauer,
48: };
49:
50: // Sicheres Schreiben in den Mutex
51: let mut guard = ergebnisse_klon.lock().unwrap();
52: guard.push(ergebnis);
53: });
54:
55: handles.push(handle);
56: }
57:
58: // Auf alle Threads warten
59: for handle in handles {
60: handle.join().unwrap();
61: }
62:
63: // Daten aus dem Arc holen. Da wir die Threads beendet haben,
64: // können wir den Vektor mittels Arc::try_unwrap extrahieren.
65: // Falls das fehlschlägt, lesen wir den Klon über Lock aus.
66: match Arc::try_unwrap(ergebnisse) {
67: Ok(mutex) => mutex.into_inner().unwrap(),
68: Err(arc) => arc.lock().unwrap().clone(),
69: }
70: }
71:
72: fn main() {
73: let urls = vec![
74: String::from("https://google.com"),
75: String::from("https://rust-lang.org"),
76: String::from("https://github.com/error_page"),
77: String::from("https://wikipedia.org"),
78: ];
79:
80: println!("Starte Scraper parallel für {} URLs...\n", urls.len());
81: let start_zeit = Instant::now();
82:
83: let ergebnisse = run_scraper(urls);
84:
85: for res in &ergebnisse {
86: println!(
87: "URL: {} -> Status: {} (Dauer: {:?})",
88: res.url, res.status, res.dauer
89: );
90: }
91:
92: println!(
93: "\nScraper beendet in insgesamt: {:?}",
94: start_zeit.elapsed()
95: );
96: }
Zeilen-Analyse der Lösung:
- Zeile 4:
use std::sync::{Arc, Mutex};– Importiert die Thread-Sicherheits-Primitive. - Zeile 10-14:
pub struct ScrapeResult– Definiert die Datenstruktur für das Ergebnis einer einzelnen URL-Abfrage. Sie speichert die URL, das Ergebnis und die gemessene Dauer. - Zeile 17:
pub fn run_scraper(urls: Vec<String>) -> Vec<ScrapeResult>– Der Einstiegspunkt unseres Scrapers. Er nimmt eine Liste von URLs per Value (Besitzübergabe) und gibt eine Liste von Ergebnissen zurück. - Zeile 19:
let ergebnisse = Arc::new(Mutex::new(Vec::new()));– Hier instanziieren wir den geschützten Ergebnis-Vektor.Arcsorgt für das Teilen auf dem Heap,Mutexgarantiert die Thread-Sicherheit. - Zeile 22:
for url in urls– Wir iterieren über jede URL der Liste. - Zeile 24:
let ergebnisse_klon = Arc::clone(&ergebnisse);– Bevor der Thread gestartet wird, klonen wir denArc-Zeiger. Dies erhöht den Referenzzähler um 1. - Zeile 27:
thread::spawn(move || { ... })– Wir spawnen den Thread. Dasmovezieht die kopierte Referenzergebnisse_klonund die URLurlin den Thread hinein. - Zeile 28:
let start = Instant::now();– Wir starten eine Stoppuhr, um die Latenz der simulierten Anfrage zu messen. - Zeile 31-32:
thread::sleep(...)– Wir legen den aktuellen Thread schlafen. Durch die mathematische modulo-Berechnungurl.len() % 5simulieren wir unterschiedliche Antwortzeiten der Webserver. - Zeile 34:
let dauer = start.elapsed();– Stoppt die Zeitmessung. - Zeile 37-41: Ein einfacher String-Vergleich simuliert einen Verbindungsfehler (Status 500), falls das Wort
"error"in der URL vorkommt. Ansonsten antwortet der Server mit"200 OK". - Zeile 51:
let mut guard = ergebnisse_klon.lock().unwrap();– Der Thread ergreift die Sperre am Mutex. Falls ein anderer Thread gerade schreibt, pausiert dieser Thread an dieser Stelle, bis er an der Reihe ist. - Zeile 52:
guard.push(ergebnis);– Wir fügen das Ergebnis in den Vektor ein. - Zeile 53: Am Ende der Closure wird der Gültigkeitsbereich verlassen. Rust räumt
guardauf und öffnet das Schloss des Mutex für den nächsten Thread. - Zeile 59-61: Wir laufen durch unsere Liste von
JoinHandles und rufen für jeden Threadjoin().unwrap()auf. Das Programm pausiert in dieser Schleife, bis auch der letzte Thread seine Arbeit beendet und seine Ergebnisse eingetragen hat. - Zeile 66-69:
Arc::try_unwrap(ergebnisse)– Da alle Hintergrund-Threads sicher beendet wurden, gibt es nur noch einen einzigen Besitzer desArcs (unseren Haupt-Thread).try_unwrapversucht, die innere Datenstruktur aus dem Arc-Wrapper herauszulösen. Mit.into_inner().unwrap()entfernen wir den Mutex-Schutz und erhalten den reinen, ungeschützten VektorVec<ScrapeResult>zurück. Das vermeidet unnötiges Klonen! - Zeile 83: Wir rufen
run_scrapermit der Liste der URLs auf. - Zeile 92-95: Wir messen die Gesamtzeit. Da alle vier URLs parallel abgefragt wurden, entspricht die Gesamtdauer des Programms nicht der Summe aller Antwortzeiten (ca. 1,4 Sekunden), sondern nur der Laufzeit des langsamsten Threads (ca. 500 Millisekunden)! Das zeigt die enorme Effizienz von paralleler Ausführung.