Parallelität und Multithreading in Rust: Fortgeschrittenes Niveau
\n\nParallelität und Multithreading gehören zu den mächtigsten und gleichzeitig komplexesten Aspekten der Systemprogrammierung. Rust bietet einen einzigartigen Ansatz: Speichersicherheitsgarantien zur Kompilierzeit, die Datenrennen (data race) und hängende Zeiger ausschließen. In diesem Artikel behandeln wir fortgeschrittene Techniken für die Arbeit mit Parallelität in Rust, einschließlich async/await, der Bibliothek Tokio, den Traits Send und Sync sowie Low-Level-Synchronisationsprimitive.
\n\nDas Material richtet sich an Entwickler, die bereits mit den Grundlagen von Rust vertraut sind und sich in hochperformante parallele Berechnungen vertiefen möchten.
\n\n1. Parallelitätsmodelle: OS-Threads und Asynchronität
\n\nRust unterstützt zwei Hauptmodelle: Betriebssystem-Threads (OS threads) und asynchrone Aufgaben (async tasks). Die Wahl zwischen ihnen hängt vom Szenario ab: Für CPU-intensive Aufgaben sind Threads besser geeignet, für I/O-gebundene Aufgaben eignet sich async.
\n\nOS-Threads (std::thread)
\n\nDas Erstellen eines Threads in Rust ist trivial, erfordert jedoch Aufmerksamkeit hinsichtlich des Datenbesitzes:
\n\nuse std::thread;\n\nlet data = vec![1, 2, 3];\n\nlet handle = thread::spawn(move || {\n println!("{:?}", data);\n});\n\nhandle.join().unwrap();\n\nDer entscheidende Punkt ist das Schlüsselwort move. Es übergibt den Besitz an die Closure. Wenn Daten zwischen Threads geteilt werden sollen, verwenden Sie Arc<Mutex<T>>.
Asynchrone Programmierung (async/await)
\n\nAsynchroner Code arbeitet innerhalb eines einzelnen Threads und wechselt zwischen Aufgaben während I/O-Wartezeiten. Dies reduziert den Overhead durch Kontextwechsel. Beispiel einer einfachen asynchronen Funktion:
\n\nuse tokio::time::{sleep, Duration};\n\n#[tokio::main]\nasync fn main() {\n let task1 = async_task("Aufgabe 1");\n let task2 = async_task("Aufgabe 2");\n\n tokio::join!(task1, task2);\n}\n\nasync fn async_task(name: &str) {\n println!("{} gestartet", name);\n sleep(Duration::from_secs(1)).await;\n println!("{} abgeschlossen", name);\n}\n\nDie Bibliothek tokio ist der Standard für asynchrones Rust. Sie bietet einen multithreaded Scheduler (work-stealing scheduler), der Aufgaben effizient auf CPU-Kerne verteilt.
2. Traits Send und Sync: Parallelitätssicherheit
\n\nZwei Schlüssel-Traits gewährleisten die Parallelitätssicherheit in Rust:
\n\n- \n
- Send — Ein Typ kann sicher zwischen Threads übertragen werden. \n
- Sync — Auf einen Typ kann sicher von mehreren Threads gleichzeitig zugegriffen werden. \n
Die meisten Rust-Primitive implementieren diese Traits automatisch, wenn ihre Komponenten sie implementieren. Beispielsweise sind Arc<Mutex<i32>> Send und Sync, während Rc<RefCell<i32>> dies nicht ist.
Betrachten wir ein Beispiel, das aufgrund einer Verletzung von Send nicht kompiliert wird:
\n\nuse std::rc::Rc;\nuse std::thread;\n\nlet data = Rc::new(5);\n\nthread::spawn(move || {\n println!("{}", data);\n}); // Fehler: Rc<i32> implementiert Send nicht\n\nDer Rust-Compiler gibt eine klare Fehlermeldung aus und verhindert so Datenrennen bereits zur Kompilierzeit. Dies ist einer der Hauptvorteile der Sprache.
\n\n3. Fortgeschrittene Synchronisation: Kanäle, Mutexe und RwLock
\n\nFür den Datenaustausch zwischen Threads bietet Rust mehrere Primitive. Betrachten wir die leistungsfähigsten.
\n\nKanäle (mpsc)
\n\nMultithreaded-Kanäle (multi-producer, single-consumer) ermöglichen das Senden von Nachrichten von mehreren Threads an einen einzelnen Empfänger:
\n\nuse std::sync::mpsc;\nuse std::thread;\n\nlet (tx, rx) = mpsc::channel();\n\nfor i in 0..3 {\n let tx_clone = tx.clone();\n thread::spawn(move || {\n tx_clone.send(i).unwrap();\n });\n}\n\ndrop(tx); // Sender schließen\n\nfor received in rx {\n println!("Empfangen: {}", received);\n}\n\nKanäle sind ideal für die "Producer-Consumer"-Architektur.