Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 RwLock und Condvar gezielt 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 ist Send, wenn der Besitz an diesem Typ sicher an einen anderen Thread übertragen werden darf.
  • Sync: Ein Typ ist Sync, wenn es sicher ist, Referenzen auf diesen Typ (&T) von mehreren Threads zeitgleich lesend zu verwenden.

Wichtige Zusammenhänge:

  • Ein Typ T ist genau dann Sync, wenn seine unveränderliche Referenz &T Send ist.
  • Fast alle primitiven Typen sind Send und Sync.
  • Rc<T> ist weder Send noch Sync, da die Referenzzähler-Updates nicht atomar sind.
  • RefCell<T> ist Send, aber nicht Sync, da die Ausleihprüfung zur Laufzeit nicht threadsicher ist.
  • Marker-Traits sind Auto-Traits: Der Compiler implementiert Send und Sync automatisch 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);
}