Zum Inhalt springen

Geteilter Zustand in Rust: RwLock, Atomics und Scoped Threads

Auf der Seite Nebenläufigkeit hast du einen Zähler mit Arc<Mutex<T>> zwischen Threads geteilt: Arc erlaubt, dass viele Threads den Wert besitzen, und Mutex sorgt dafür, dass immer nur einer davon ihn anfasst. Auf der Seite Channels hast du das Teilen ganz vermieden und stattdessen Nachrichten geschickt. Beides sind tolle Werkzeuge, und du kannst damit fast alles bauen.

Aber stell dir einen Mutex wie den einzigen Schlüssel zur Büro-Toilette vor. Das funktioniert, ist aber übertrieben, wenn du nur das Schwarze Brett im Flur lesen willst. Die Standardbibliothek von Rust hat noch ein paar weitere Werkzeuge, und jedes passt besser zu einer bestimmten Aufgabe. Auf dieser Seite lernst du vier davon kennen: Scoped Threads, RwLock, Atomics und OnceLock/LazyLock. Außerdem erfährst du, wie du den einen Fehler vermeidest, den der Compiler nicht für dich finden kann: den Deadlock (Verklemmung).

Mit thread::spawn brauchst du normalerweise move und oft auch Arc. Der Grund: Ein gestarteter Thread könnte noch weiterlaufen, wenn main schon weitergemacht hat. Deshalb lässt Rust ihn deine lokalen Variablen nicht ausleihen.

std::thread::scope löst das mit einem Versprechen: Jeder Thread, der im Scope (Gültigkeitsbereich) gestartet wird, ist fertig, bevor der Scope endet. Weil die Threads deine Daten nicht überleben können, dürfen sie sie einfach ausleihen. Kein Arc, kein move, kein Klonen.

use std::thread;
fn main() {
let shop = "Eckladen";
let sales = vec![12, 7, 30, 5, 18, 22, 9, 14];
let (morning, afternoon) = sales.split_at(4);
let (am, pm) = thread::scope(|s| {
let am = s.spawn(|| morning.iter().sum::<i32>());
let pm = s.spawn(|| afternoon.iter().sum::<i32>());
(am.join().unwrap(), pm.join().unwrap())
});
println!("{shop}: vormittags {am}, nachmittags {pm}, ganzer Tag {}", am + pm);
println!("gehört immer noch mir: {:?}", sales);
}

Ausgabe:

Eckladen: vormittags 54, nachmittags 63, ganzer Tag 117
gehört immer noch mir: [12, 7, 30, 5, 18, 22, 9, 14]
▶ Selbst ausprobieren

Das passiert hier:

  • thread::scope(|s| { ... }) öffnet den Scope. Threads startest du mit s.spawn(...) statt mit thread::spawn(...).
  • Die beiden Closures leihen sich morning und afternoon aus. Das sind nur Slices von sales. Nichts wird kopiert.
  • Was die Closure des Scopes zurückgibt (hier ein Tupel aus zwei Summen), wird zum Wert von thread::scope.
  • Nach dem Scope gehört sales immer noch main, du kannst es also weiter benutzen.

Selbst wenn du vergisst, join() aufzurufen, wartet der Scope an der schließenden } auf alle seine Threads. Genau dieses Versprechen macht das Ausleihen sicher.

Stell dir die Menütafel in einem Café vor. Viele Gäste können sie gleichzeitig lesen, und das ist völlig in Ordnung. Aber wenn die Besitzerin einen Preis ändert, müssen alle kurz warten, bis die neue Tafel hängt.

Genau das ist ein RwLock (ein „Read-Write-Lock“, also eine Lese-Schreib-Sperre). Er hat zwei Eingänge:

  • read() gibt dir einen geteilten Lese-Guard. Viele Threads können gleichzeitig einen halten.
  • write() gibt dir einen exklusiven Schreib-Guard. Solange ein Schreiber ihn hält, kann niemand sonst lesen oder schreiben.

Ein Mutex macht diesen Unterschied nicht: Selbst zwei Leser müssen sich abwechseln. Hier ist ein Cache für Einstellungen, den mehrere Request-Handler lesen und den ein Admin einmal ändert:

use std::sync::{Arc, RwLock};
use std::thread;
struct Settings {
theme: String,
max_upload_mb: u32,
}
fn main() {
let settings = Arc::new(RwLock::new(Settings {
theme: String::from("hell"),
max_upload_mb: 10,
}));
// Vier Request-Handler lesen die Einstellungen gleichzeitig.
let mut handles = Vec::new();
for id in 1..=4 {
let settings = Arc::clone(&settings);
handles.push(thread::spawn(move || {
let s = settings.read().unwrap(); // geteilt: andere können auch lesen
format!("Anfrage {id}: Theme={}, Limit={}MB", s.theme, s.max_upload_mb)
}));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
// Ein Admin ändert die Einstellungen einmal.
{
let mut s = settings.write().unwrap(); // exklusiv: alle anderen warten
s.theme = String::from("dunkel");
s.max_upload_mb = 25;
} // hier wird der Schreib-Guard gedroppt, der Lock ist wieder frei
let s = settings.read().unwrap();
println!("nach dem Update: Theme={}, Limit={}MB", s.theme, s.max_upload_mb);
}

Ausgabe:

Anfrage 1: Theme=hell, Limit=10MB
Anfrage 2: Theme=hell, Limit=10MB
Anfrage 3: Theme=hell, Limit=10MB
Anfrage 4: Theme=hell, Limit=10MB
nach dem Update: Theme=dunkel, Limit=25MB
▶ Selbst ausprobieren

Die Threads können in beliebiger Reihenfolge fertig werden, aber die Ausgabe ist immer gleich. Jeder Thread gibt seine Zeile zurück, statt sie selbst auszugeben, und main gibt sie in der Reihenfolge aus, in der es die Threads mit join einsammelt.

Wann ist RwLock besser als Mutex? Wenn oft gelesen und selten geschrieben wird und jeder Lesevorgang etwas Zeit braucht (zum Beispiel das Durchsuchen eines großen Caches). Dann laufen die Leser wirklich nebeneinander.

Wann nicht? Wenn die Lesevorgänge winzig sind oder ständig geschrieben wird, ist ein RwLock nur ein komplizierterer Mutex. Er hat zusätzlichen Verwaltungsaufwand, und Leser und Schreiber warten sowieso meistens aufeinander. Wenn du unsicher bist, fang mit einem Mutex an. Der ist einfacher, und du kannst später wechseln, falls Messungen zeigen, dass der Lock ein Flaschenhals ist.

Für eine einzelne Zahl oder ein einzelnes Wahr/Falsch-Flag ist selbst ein Lock mehr, als du brauchst. Atomare Typen wie AtomicUsize, AtomicI64 und AtomicBool können von vielen Threads gleichzeitig sicher geändert werden, ganz ohne Lock. Die CPU führt jede Operation in einem einzigen, unteilbaren Schritt aus. So geht kein Update verloren.

Hier zählen 8 Threads jeweils 1000 Seitenaufrufe. Außerdem zählen sie Stimmen: eine Stimme dafür, dann drei dagegen, immer wieder. Die Stimmbilanz wird negativ, deshalb ist sie ein AtomicI64 und kein vorzeichenloser Typ:

use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
use std::thread;
fn main() {
let page_views = AtomicUsize::new(0);
let net_votes = AtomicI64::new(0);
thread::scope(|s| {
for _ in 0..8 {
s.spawn(|| {
for n in 0..1000 {
page_views.fetch_add(1, Ordering::Relaxed);
if n % 4 == 0 {
net_votes.fetch_add(1, Ordering::Relaxed); // Stimme dafür
} else {
net_votes.fetch_sub(1, Ordering::Relaxed); // Stimme dagegen
}
}
});
}
}); // hier sind alle 8 Threads fertig
println!("Seitenaufrufe: {}", page_views.load(Ordering::Relaxed));
println!("Stimmbilanz: {}", net_votes.load(Ordering::Relaxed));
}

Ausgabe:

Seitenaufrufe: 8000
Stimmbilanz: -4000
▶ Selbst ausprobieren

Fällt dir auf, dass page_views nicht mut ist? Atomics werden über eine geteilte Referenz geändert. Genau deshalb können viele Threads sie gleichzeitig benutzen. Die Methoden, die du am häufigsten brauchst:

  • fetch_add(n, ordering) / fetch_sub(n, ordering) addieren oder subtrahieren und geben den alten Wert zurück.
  • load(ordering) liest den aktuellen Wert.
  • store(value, ordering) überschreibt ihn.

Jede atomare Operation verlangt ein Ordering. Es sagt CPU und Compiler, wie streng diese Operation in der Reihenfolge mit den Speicherzugriffen um sie herum bleiben muss. Die ganze Geschichte ist tief, aber als Anfänger brauchst du nur zwei Regeln:

  • Wenn du unsicher bist, nimm Ordering::SeqCst. Das ist die strengste und sicherste Wahl.
  • Ordering::Relaxed reicht für einen einfachen Zähler, den du nur am Ende ausliest, wie den oben. Die Summe stimmt trotzdem exakt.

Wenn du eines Tages tiefer einsteigen willst: Das kostenlose Buch Rust Atomics and Locks (Link ganz unten) erklärt Orderings sehr verständlich.

Ein AtomicBool ist ein prima „Bitte aufhören“-Signal. Hier speichert ein Worker im Hintergrund immer wieder automatisch, bis main das Flag umlegt:

use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
fn main() {
let stop = AtomicBool::new(false);
let report = thread::scope(|s| {
let worker = s.spawn(|| {
while !stop.load(Ordering::SeqCst) {
// hier würde das Dokument automatisch gespeichert
thread::sleep(Duration::from_millis(5));
}
"Autosave-Worker sauber beendet"
});
thread::sleep(Duration::from_millis(30)); // main macht andere Arbeit
stop.store(true, Ordering::SeqCst); // den Worker bitten, aufzuhören
worker.join().unwrap()
});
println!("{report}");
}

Ausgabe:

Autosave-Worker sauber beendet
▶ Selbst ausprobieren

Der Worker prüft das Flag bei jedem Schleifendurchlauf. Er erledigt seinen aktuellen Schritt und beendet sich dann von selbst. Das ist viel freundlicher, als einen Thread mitten im Speichern einer Datei abzuwürgen.

Manchmal willst du einen globalen Wert, den jeder Thread lesen kann: eine Nachschlagetabelle, einen App-Namen, eine Konfiguration, die beim Start geladen wird. Das Schwierige ist das Erstellen. Was, wenn zwei Threads ihn genau im selben Moment bauen wollen?

std::sync::LazyLock und std::sync::OnceLock lösen das. Der Wert wird genau einmal erstellt, nämlich dann, wenn ihn zum ersten Mal jemand braucht. Fragen andere Threads, während er noch gebaut wird, warten sie einfach. Danach teilen sich alle denselben Wert.

use std::collections::HashMap;
use std::sync::{LazyLock, OnceLock};
use std::thread;
// LazyLock: Das Rezept steht direkt hier und läuft bei der ersten Benutzung.
static CURRENCIES: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| {
println!("baue die Währungstabelle...");
HashMap::from([("US", "USD"), ("JP", "JPY"), ("DE", "EUR"), ("BR", "BRL")])
});
// OnceLock: startet leer, und du entscheidest dort, wo du ihn benutzt, wie er gefüllt wird.
static STORE_NAME: OnceLock<String> = OnceLock::new();
fn store_name() -> &'static str {
STORE_NAME.get_or_init(|| {
std::env::var("STORE_NAME").unwrap_or_else(|_| String::from("Rusty Shop"))
})
}
fn main() {
let mut handles = Vec::new();
for country in ["US", "JP", "DE", "BR"] {
handles.push(thread::spawn(move || {
format!("{} in {country} rechnet in {} ab", store_name(), CURRENCIES[country])
}));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
}

Ausgabe:

baue die Währungstabelle...
Rusty Shop in US rechnet in USD ab
Rusty Shop in JP rechnet in JPY ab
Rusty Shop in DE rechnet in EUR ab
Rusty Shop in BR rechnet in BRL ab
▶ Selbst ausprobieren

Vier Threads wollen die Tabelle ungefähr gleichzeitig haben, und trotzdem erscheint „baue die Währungstabelle…“ nur einmal. Und weil das static-Werte sind, die während des ganzen Programms leben, brauchen die Threads überhaupt kein Arc.

Welchen solltest du nehmen?

  • LazyLock, wenn du an der Stelle, wo du ihn deklarierst, schon weißt, wie der Wert gebaut wird. Er fühlt sich an wie ein normaler globaler Wert.
  • OnceLock, wenn der Wert erst später feststeht, zum Beispiel durch ein Kommandozeilen-Flag. Du füllst ihn mit get_or_init(...) oder set(...) (und set schlägt höflich fehl, wenn er schon gefüllt ist).

Beide gehören zur Standardbibliothek. LazyLock kam mit Rust 1.80. Älterer Code benutzt für denselben Zweck deshalb oft die Crates once_cell oder lazy_static.

Der Compiler verhindert Data Races, aber einen Nebenläufigkeitsfehler kann er nicht erkennen: den Deadlock. Dabei warten Threads für immer aufeinander, und dein Programm friert einfach ein. Kein Fehler, kein Absturz, nichts.

Das klassische Rezept dafür sind zwei Locks, die in unterschiedlicher Reihenfolge genommen werden. Stell dir zwei Leute vor, die beide den Stift und das Notizbuch brauchen. Die eine schnappt sich den Stift, der andere das Notizbuch, und jeder wartet höflich darauf, dass der andere loslässt. Für immer.

// Thread A // Thread B
let from = alice.lock().unwrap(); let from = bob.lock().unwrap();
let to = bob.lock().unwrap(); let to = alice.lock().unwrap();
// A hält alice, wartet auf bob. // B hält bob, wartet auf alice. Klemmt!

Drei Gewohnheiten schützen dich:

  1. Nimm Locks immer in derselben Reihenfolge. Wenn jeder Thread alice vor bob sperrt (zum Beispiel sortiert nach Konto-ID), kann der Kreis nicht entstehen.
  2. Halte Locks nur kurz. Halte einen Lock nur so lange, wie du zum Lesen oder Ändern der Daten brauchst. Halte ihn nie während langsamer Arbeit wie einem Netzwerkaufruf, dem Schreiben einer Datei oder einer langen Berechnung. Alle anderen Threads hängen die ganze Zeit fest und warten.
  3. Droppe Guards früh. Ein Guard gibt den Lock frei, sobald er gedroppt wird. Du kannst ihn mit einem inneren Block { ... } beenden (wie beim RwLock-Schreiber oben) oder selbst drop(guard) aufrufen.

Hier ist Gewohnheit 3 in Aktion. Jeder Worker nimmt sich einen Auftrag aus einer geteilten Warteschlange, gibt die Warteschlange frei und erledigt erst dann den langsamen Teil:

use std::sync::Mutex;
use std::thread;
fn main() {
let queue = Mutex::new(vec!["Foto verkleinern", "E-Mail senden", "Bericht erstellen"]);
let finished = Mutex::new(Vec::new());
thread::scope(|s| {
for _ in 0..3 {
s.spawn(|| {
let mut jobs = queue.lock().unwrap();
let job = jobs.pop();
drop(jobs); // die Warteschlange VOR dem langsamen Teil freigeben
if let Some(job) = job {
let result = format!("erledigt: {job}"); // stell dir vor, das dauert Sekunden
finished.lock().unwrap().push(result);
}
});
}
});
// Der Scope ist vorbei, kein Thread benutzt `finished` mehr.
let mut done = finished.into_inner().unwrap();
done.sort(); // Threads werden in beliebiger Reihenfolge fertig
for line in done {
println!("{line}");
}
}

Ausgabe:

erledigt: Bericht erstellen
erledigt: E-Mail senden
erledigt: Foto verkleinern
▶ Selbst ausprobieren

Ohne drop(jobs) würde der Guard bis zum Ende der Closure leben. Dann würde die „langsame“ Arbeit passieren, während die Warteschlange noch gesperrt ist, und die drei Worker würden nacheinander statt nebeneinander laufen.

Einen hinterhältigen Fall solltest du kennen: In while let Some(job) = queue.lock().unwrap().pop() { ... } lebt der Guard für den ganzen Schleifenrumpf. Die Warteschlange bleibt also gesperrt, während du an dem Auftrag arbeitest. Hol dir den Auftrag deshalb zuerst in einer eigenen let-Anweisung, so wie im Beispiel.

Du willst … Nimm
Arbeit oder Ergebnisse von einem Thread an einen anderen schicken Einen Channel (mpsc)
Viele Threads denselben Wert lesen und ändern lassen Arc<Mutex<T>>
Daten teilen, die oft gelesen und selten geändert werden Arc<RwLock<T>>
Dinge zählen oder ein einfaches An/Aus-Flag teilen Atomics (AtomicUsize, AtomicBool, …)
Einen globalen Wert einmal bauen und danach nur lesen LazyLock oder OnceLock
Threads lokale Daten ausleihen lassen, ganz ohne Arc thread::scope

Diese Werkzeuge lassen sich frei kombinieren. Die Beispiele auf dieser Seite haben thread::scope schon mit Atomics und mit einem Mutex verbunden. Nimm das einfachste Werkzeug, das passt, und mach dir keine Sorgen, das „falsche“ zu erwischen: Später zu wechseln ist meistens eine kleine Änderung.

Eine lokale Variable in thread::spawn ausleihen. Das sieht harmlos aus:

use std::thread;
fn main() {
let sales = vec![12, 7, 30, 5];
let handle = thread::spawn(|| {
let total: i32 = sales.iter().sum();
total
});
println!("Summe: {}", handle.join().unwrap());
}
error[E0373]: closure may outlive the current function, but it borrows `sales`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `sales`
7 | let total: i32 = sales.iter().sum();
| ----- `sales` is borrowed here
|
help: to force the closure to take ownership of `sales` (and any other referenced variables), use the `move` keyword

Der Compiler sagt: Die Closure könnte länger leben als die aktuelle Funktion, leiht sich aber sales aus, das dieser Funktion gehört. Du weißt, dass du gleich danach join() aufrufst – aber thread::spawn kann das nicht wissen. Aus seiner Sicht könnte der Thread ewig laufen. Du hast zwei Lösungen: move hinzufügen (dann kann main sales danach nicht mehr benutzen) oder thread::scope verwenden, das ausleihen kann:

let total: i32 = thread::scope(|s| s.spawn(|| sales.iter().sum()).join().unwrap());

Arc vergessen. Ohne Arc nimmt sich die move-Closure des ersten Threads den Mutex, und für den nächsten bleibt nichts übrig:

use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..3 {
handles.push(thread::spawn(move || {
*counter.lock().unwrap() += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
}
error[E0382]: use of moved value: `counter`
--> src/main.rs:9:36
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..3 {
| ------------- inside of this loop
9 | handles.push(thread::spawn(move || {
| ^^^^^^^ value moved into closure here, in previous iteration of loop

Der Compiler sagt: counter wurde schon verschoben – nämlich in einem früheren Schleifendurchlauf in die Closure. Pack den Wert in Arc::new(...) und klone den Arc am Anfang der Schleife, wie auf der Seite Nebenläufigkeit. Oder nimm thread::scope und lass das move weg. Wenn du stattdessen zu Rc statt Arc greifst, bekommst du error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely (er kann nicht sicher zwischen Threads verschickt werden). Rc ist nur für einen einzigen Thread gedacht (siehe Smart Pointer).

Erstelle einen Vec mit den Zahlen 1 bis 1000. Teile ihn mit thread::scope in 4 Stücke zu je 250 und summiere jedes Stück in einem eigenen Thread. Gib die Summe jedes Stücks und die Gesamtsumme aus. Tipp: numbers.chunks(250) liefert dir die Slices. Sammle die Handles in einem Vec und rufe dann der Reihe nach join auf.

Lösung anzeigen
use std::thread;
fn main() {
let numbers: Vec<u64> = (1..=1000).collect();
let sums: Vec<u64> = thread::scope(|s| {
let mut handles = Vec::new();
for chunk in numbers.chunks(250) {
handles.push(s.spawn(move || chunk.iter().sum::<u64>()));
}
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
println!("Teilsummen: {:?}", sums);
println!("Gesamt: {}", sums.iter().sum::<u64>());
}

Ausgabe:

Teilsummen: [31375, 93875, 156375, 218875]
Gesamt: 500500
▶ Selbst ausprobieren

Das move verschiebt hier nur chunk in jeden Thread, und das ist bloß ein kleiner ausgeliehener Slice. Der Vektor selbst wird weiterhin nur ausgeliehen, nicht verschoben. Du könntest numbers nach dem Scope also wieder benutzen.

Ein Onlineshop gibt jeder Bestellung eine Nummer, beginnend bei 1000. Starte 4 Threads, die sich jeweils 3 Bestellnummern aus einem gemeinsamen AtomicU64 holen. Sammle alle 12 Nummern, sortiere sie und gib sie aus. Tipp: fetch_add(1, Ordering::SeqCst) gibt den Wert vor dem Addieren zurück und vergibt so jede Nummer genau einmal.

Lösung anzeigen
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
fn main() {
let next_order = AtomicU64::new(1000);
let mut numbers: Vec<u64> = thread::scope(|s| {
let mut handles = Vec::new();
for _ in 0..4 {
handles.push(s.spawn(|| {
(0..3)
.map(|_| next_order.fetch_add(1, Ordering::SeqCst))
.collect::<Vec<u64>>()
}));
}
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
});
numbers.sort();
println!("{} Bestellungen: {:?}", numbers.len(), numbers);
}

Ausgabe:

12 Bestellungen: [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011]
▶ Selbst ausprobieren

Welcher Thread welche Nummer bekommt, ändert sich von Lauf zu Lauf. Aber keine Nummer wird je doppelt vergeben oder übersprungen. Durch das Sortieren sieht die ausgegebene Liste jedes Mal gleich aus.

  • thread::scope garantiert, dass seine Threads fertig sind, bevor er endet. Deshalb können sie lokale Daten ohne Arc oder move ausleihen.
  • RwLock erlaubt viele Leser oder einen Schreiber. Das hilft bei Daten, die vor allem gelesen werden; sonst ist ein Mutex einfacher.
  • Atomics (AtomicUsize, AtomicI64, AtomicBool) erledigen Zähler und Flags ganz ohne Lock. Nimm SeqCst, wenn du unsicher bist, und Relaxed für einfache Zähler.
  • LazyLock und OnceLock erstellen einen globalen Wert genau einmal und sicher, selbst wenn viele Threads gleichzeitig danach fragen.
  • So vermeidest du Deadlocks: Locks immer in derselben Reihenfolge nehmen, nur kurz halten und Guards früh droppen.

Kurzer Check

1. Warum dürfen Threads, die mit s.spawn in thread::scope gestartet werden, lokale Variablen ausleihen?

2. Welche Aussage über RwLock stimmt?

3. Du brauchst einen Zähler für Seitenaufrufe, den viele Threads teilen. Was ist die einfachste gute Wahl?

4. Thread A sperrt alice und dann bob. Thread B sperrt bob und dann alice. Was kann passieren?

Punkte: 0 / 4