Kapitel 16: Nebenläufigkeit und asynchrone Programmierung – Threadsicherheit und Asynchronität
Der Entwurf hochperformanter, nebenläufiger Systeme erfordert ein tiefes Verständnis der Typsicherheitsgarantien des Compilers sowie die Fähigkeit, zwischen Thread-basierter Nebenläufigkeit und asynchronem I/O abzuwägen.
Während Threads ideal für CPU-intensive Berechnungen (z. B. Bildverarbeitung, mathematische Simulationen) sind, eignet sich die asynchrone Programmierung perfekt für I/O-intensive Aufgaben (z. B. Webserver, Datenbankabfragen), bei denen das Programm die meiste Zeit auf externe Antworten wartet.
1. Lernziele – Das wirst du heute lernen
- Send und Sync beherrschen: Sie verstehen die Funktionsweise von Marker-Traits und Auto-Traits.
- Fortgeschrittene Synchronisation: Sie setzen
RwLockundCondvargezielt ein. - Lockfreie Programmierung: Sie nutzen atomare Operationen zur Synchronisation ohne Sperren.
- Interior Mutability mit RefCell: Sie nutzen innere Veränderlichkeit zur Laufzeit.
- Asynchrone Syntax (
async/await): Sie schreiben I/O-effizienten asynchronen Code. - Die Funktionsweise von Runtimes: Sie steuern asynchrone Aufgaben über die Tokio-Runtime.
2. Die Marker-Traits Send und Sync
Das Fundament der Threadsicherheitsgarantie in Rust bilden zwei eingebaute Marker-Traits (Traits, die keine Methoden deklarieren, sondern nur semantische Eigenschaften markieren):
Send: Ein Typ istSend, wenn der Besitz an diesem Typ sicher an einen anderen Thread übertragen werden darf.Sync: Ein Typ istSync, wenn es sicher ist, Referenzen auf diesen Typ (&T) von mehreren Threads zeitgleich lesend zu verwenden.
Wichtige Zusammenhänge:
- Ein Typ
Tist genau dannSync, wenn seine unveränderliche Referenz&TSendist. - Fast alle primitiven Typen sind
SendundSync. Rc<T>ist wederSendnochSync, da die Referenzzähler-Updates nicht atomar sind.RefCell<T>istSend, aber nichtSync, da die Ausleihprüfung zur Laufzeit nicht threadsicher ist.- Marker-Traits sind Auto-Traits: Der Compiler implementiert
SendundSyncautomatisch für Ihre Datenstrukturen, sofern alle darin enthaltenen Typen ebenfalls diese Eigenschaften besitzen.
3. Fortgeschrittene Synchronisationsprimitive
RwLock (Reader-Writer Lock)
Ein RwLock verhält sich analog zu Rusts Aliasing-Regeln: Es erlaubt entweder beliebig viele parallele Leser ODER maximal einen Schreiber exklusiv. Dies ist deutlich effizienter als ein standardmäßiger Mutex, wenn Daten häufig gelesen, aber selten verändert werden.
use std::sync::RwLock;
fn main() {
let daten = RwLock::new(5);
// Paralleler Lesezugriff
{
let r1 = daten.read().unwrap();
let r2 = daten.read().unwrap();
println!("Lesewerte: {}, {}", *r1, *r2);
} // Lese-Sperren erlöschen hier
// Exklusiver Schreibzugriff
{
let mut w = daten.write().unwrap();
*w += 1;
}
}
Condvar (Bedingungsvariablen)
Eine Condvar erlaubt es einem Thread, so lange ressourcenschonend zu blockieren (zu schlafen), bis eine bestimmte Bedingung eintritt. Sie wird immer zusammen mit einem Mutex betrieben:
use std::sync::{Arc, Mutex, Condvar};
use std::thread;
fn main() {
let start_signal = Arc::new((Mutex::new(false), Condvar::new()));
let signal_klon = Arc::clone(&start_signal);
thread::spawn(move || {
let (lock, cvar) = &*signal_klon;
let mut gestartet = lock.lock().unwrap();
*gestartet = true;
cvar.notify_one(); // Den wartenden Thread aufwecken
});
let (lock, cvar) = &*start_signal;
let mut gestartet = lock.lock().unwrap();
// Schleife schützt vor Spurious Wakeups (Fehlalarmen des OS)
while !*gestartet {
gestartet = cvar.wait(gestartet).unwrap(); // Gibt das Lock temporär frei
}
println!("Start-Signal erhalten!");
}
4. Lockfreie Programmierung mit Atomics
Für einfache numerische Operationen ist das Sperren eines Mutex oft zu rechenintensiv, da es Betriebssystem-Systemaufrufe involviert. Rust bietet im Modul std::sync::atomic Typen an, die direkt über CPU-Befehle synchronisiert werden:
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
let zaehler = Arc::new(AtomicUsize::new(0));
let mut handles = vec![];
for _ in 0..10 {
let klon = Arc::clone(&zaehler);
let handle = thread::spawn(move || {
// fetch_add arbeitet threadsicher auf Hardware-Ebene
klon.fetch_add(1, Ordering::Relaxed);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Stand: {}", zaehler.load(Ordering::Relaxed)); // 10
}
Hinweis zur Speicherordnung (Ordering): Wir nutzen hier Ordering::Relaxed, was dem Compiler erlaubt, die Speicheroperationen im Sinne der Performance umzusortieren. Für komplexe lockfreie Datenstrukturen müssen stärkere Garantien wie Acquire und Release verwendet werden.
5. Asynchrones Rust: async und await
Im Gegensatz zu OS-Threads sind asynchrone Tasks extrem leichtgewichtig. Sie verhalten sich wie kooperative Green Threads. Das Umschalten zwischen Tasks erfordert keinen Context Switch des Betriebssystems.
Die async/await-Syntax
Eine async fn gibt eine Struktur zurück, die das Future-Trait implementiert. Ein Future ist ein Zustandsautomat, der auf seine Auswertung wartet.
#![allow(unused)]
fn main() {
// Eine asynchrone Funktion
async fn daten_laden() -> String {
// Simuliert asynchrones Warten
String::from("Daten fertig geladen")
}
async fn verarbeiten() {
// .await pausiert den aktuellen Task (gibt die CPU frei),
// bis das Future aufgelöst ist.
let ergebnis = daten_laden().await;
println!("{}", ergebnis);
}
}
Die Runtime (Tokio)
Da Rusts Standardbibliothek keinen eingebauten Executor besitzt, müssen wir eine externe Runtime wie Tokio nutzen:
#[tokio::main]
async fn main() {
// Nebenläufige Ausführung von zwei asynchronen Tasks
let task1 = daten_laden();
let task2 = daten_laden();
let (res1, res2) = tokio::join!(task1, task2);
println!("Ergebnisse: {} & {}", res1, res2);
}