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.
Einen Thread starten
Abschnitt betitelt „Einen Thread starten“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}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.
Daten in einen Thread verschieben
Abschnitt betitelt „Daten in einen Thread verschieben“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 ThreadOhne 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.
Daten sicher zwischen Threads teilen
Abschnitt betitelt „Daten sicher zwischen Threads teilen“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: 5counter.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.
Warum Rust das „Fearless Concurrency“ nennt
Abschnitt betitelt „Warum Rust das „Fearless Concurrency“ nennt“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“.
Übungen
Abschnitt betitelt „Übungen“1. Zwei Threads, eine Summe
Abschnitt betitelt „1. Zwei Threads, eine Summe“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 = 5050Diese 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.
2. Alle tragen sich ins Gästebuch ein
Abschnitt betitelt „2. Alle tragen sich ins Gästebuch ein“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 hierThread 2 war hierThread 3 war hierLö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.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“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 mitMutex(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
Abschnitt betitelt „Kurzer Check“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
Weiterlesen
Abschnitt betitelt „Weiterlesen“- Programmcode mit Threads gleichzeitig ausführen – Das Rust-Buch:
spawn,joinundmove-Closures, mit Beispielen dafür, wie sich die Ausgaben mischen. - Nebenläufigkeit mit gemeinsamem Zustand – Das Rust-Buch:
MutexundArcim Detail, auch warum duRcnicht an einen anderen Thread schicken kannst. std::thread– Doku der Standardbibliothek (auf Englisch): alles über Threads, auch wie du sie benennst und was Scoped Threads sind.Mutex– Doku der Standardbibliothek (auf Englisch): wie das Sperren funktioniert und was „Poisoning“ bedeutet, wenn ein Thread eine Panic auslöst, während er die Sperre hält.- Threads – Rust by Example (auf Englisch): ein kurzes, ausführbares Beispiel, das mehrere Threads startet und auf sie wartet.