Nebenläufigkeit mit gemeinsamem Zustand
Die Nachrichtenübermittlung ist eine gute Methode zur Behandlung von Nebenläufigkeit, aber sie ist nicht die einzige. Eine andere Methode wäre, dass mehrere Threads auf dieselben gemeinsamen Daten zugreifen. Betrachte folgenden Teil des Slogans aus der Go-Sprachdokumentation noch einmal: „Kommuniziere nicht, indem du Arbeitsspeicher teilst.“
Wie würde Kommunikation durch gemeinsame Nutzung von Arbeitsspeicher aussehen? Und warum sollten Liebhaber der Nachrichtenübermittlung davor warnen, gemeinsamen Arbeitsspeicher zu verwenden?
In gewisser Weise ähneln Kanäle in jeder Programmiersprache dem Alleineigentum, denn sobald du einen Wert in einen Kanal übertragen hast, solltest du diesen Wert nicht mehr verwenden. Nebenläufigkeit mit gemeinsam genutztem Arbeitsspeicher ist wie Mehrfacheigentum: Mehrere Threads können gleichzeitig auf denselben Speicherplatz zugreifen. Wie du in Kapitel 15 gesehen hast, wo intelligente Zeiger Mehrfacheigentum ermöglichten, kann Mehrfacheigentum zu zusätzlicher Komplexität führen, da die verschiedenen Eigentümer verwaltet werden müssen. Das Typsystem und die Eigentumsregeln von Rust sind eine große Hilfe, um diese Verwaltung korrekt zu gestalten. Betrachten wir als Beispiel den Mutex, eines der gebräuchlicheren Nebenläufigkeitsprimitive für gemeinsam genutzten Speicher.
Datenzugriff steuern mit Mutexen
Mutex ist eine Abkürzung für mutual exclusion (wechselseitiger Ausschluss), da ein Mutex zu einem bestimmten Zeitpunkt nur einem Thread (thread) den Zugriff auf Daten erlaubt. Um auf die Daten in einem Mutex zuzugreifen, muss ein Thread zunächst signalisieren, dass er Zugriff wünscht, indem er darum bittet, die Sperre (lock) des Mutex zu erwerben. Die Sperre ist eine Datenstruktur, die Teil des Mutex ist, der verfolgt, wer derzeit exklusiven Zugriff auf die Daten hat. Daher wird der Mutex als Schutz der Daten beschrieben, die er über das Sperrsystem hält.
Mutexe haben den Ruf, dass sie schwierig anzuwenden sind, weil man sich zwei Regeln merken muss:
- Du musst versuchen, die Sperre zu erwerben, bevor du die Daten verwendest.
- Wenn du mit den Daten, die der Mutex schützt, fertig bist, musst du die Daten entsperren, damit andere Threads die Sperre übernehmen können.
Als reale Metapher für einen Mutex stelle dir eine Podiumsdiskussion auf einer Konferenz mit nur einem Mikrofon vor. Bevor ein Podiumsteilnehmer das Wort ergreifen kann, muss er fragen oder signalisieren, dass er das Mikrofon benutzen möchte. Wenn er das Mikrofon erhält, kann er so lange sprechen, wie er möchte, und das Mikrofon dann dem nächsten Diskussionsteilnehmer übergeben, der um das Wort bittet. Wenn ein Diskussionsteilnehmer vergisst, das Mikrofon abzugeben, wenn er damit fertig ist, kann kein anderer mehr sprechen. Wenn die Verwaltung des gemeinsam genutzten Mikrofons schief geht, funktioniert das Podium nicht wie geplant!
Das Management von Mutexen kann unglaublich schwierig sein, weshalb so viele Menschen von Kanälen begeistert sind. Dank des Typsystems und der Eigentumsregeln von Rust kann man jedoch beim Sperren und Entsperren nichts falsch machen.
Die API von Mutex<T>
Als Beispiel für die Verwendung eines Mutex beginnen wir mit der Verwendung eines Mutex in einem single-threaded Kontext, wie in Listing 16-12 gezeigt.
Dateiname: src/main.rs
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {m:?}");
}
Listing 16-12: Untersuchen der API von Mutex<T> in
einem single-threaded Kontext zur Vereinfachung
Wie bei vielen Typen erzeugen wir einen Mutex<T> mit der zugehörigen Funktion
new. Um auf die Daten innerhalb des Mutex zuzugreifen, verwenden wir die
Methode lock, um die Sperre zu erhalten. Dieser Aufruf blockiert den
aktuellen Thread, sodass er keine Arbeit verrichten kann, bis wir an der Reihe
sind und die Sperre bekommen.
Der Aufruf von lock würde fehlschlagen, wenn ein anderer Thread, der die
Sperre hält, abbricht. In diesem Fall wäre niemand jemals in der Lage, die
Sperre zu erhalten, also haben wir uns entschieden, unwrap zu benutzen und
diesen Thread abzubrechen, wenn wir uns in dieser Situation befinden.
Nachdem wir die Sperre bekommen haben, können wir den Rückgabewert, in diesem
Fall num genannt, als veränderbare Referenz auf die darin enthaltenen Daten
verwenden. Das Typsystem stellt sicher, dass wir eine Sperre erwerben, bevor
wir den Wert in m verwenden. Der Typ von m ist Mutex<i32>, nicht i32,
also müssen wir lock aufrufen, um den i32-Wert verwenden zu können. Wir
können das nicht vergessen, das Typsystem würde uns sonst keinen Zugriff auf
das innere i32 erlauben.
Wie du vielleicht vermutest, ist Mutex<T> ein intelligenter Zeiger (smart
pointer). Genauer gesagt gibt der Aufruf von lock einen intelligenten Zeiger
namens MutexGuard zurück, der in ein LockResult verpackt ist, das wir mit
dem Aufruf von unwrap behandelt haben. Der intelligente Zeiger MutexGuard
implementiert Deref, um auf unsere inneren Daten zu zeigen; der intelligente
Zeiger hat auch eine Drop-Implementierung, die die Sperre automatisch aufhebt,
wenn ein MutexGuard den Gültigkeitsbereich verlässt, was am Ende des inneren
Gültigkeitsbereichs geschieht. Dadurch laufen wir nicht Gefahr, zu vergessen,
die Sperre freizugeben und die Verwendung des Mutex durch andere Threads zu
blockieren, da die Freigabe der Sperre automatisch erfolgt.
Nachdem wir die Sperre aufgehoben haben, können wir den Mutex-Wert ausgeben und
sehen, dass wir den inneren i32 in 6 ändern konnten.
Gemeinsamer Zugriff auf Mutex<T>
Versuchen wir nun, einen Wert zwischen mehreren Threads mit Mutex<T> zu
teilen. Wir starten 10 Threads und lassen sie jeweils einen Zählerwert um 1
erhöhen, sodass der Zähler von 0 auf 10 geht. Das Beispiel in Listing 16-13 wird
einen Compilerfehler haben und wir werden diesen Fehler verwenden, um mehr über
die Verwendung von Mutex<T> zu erfahren und wie Rust uns hilft, ihn korrekt zu
verwenden.
Dateiname: src/main.rs
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..10 {
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());
}
Listing 16-13: Zehn Threads inkrementieren jeweils
einen Zähler, der durch einen Mutex<T> geschützt ist
Wir erstellen eine Variable counter, um ein i32 innerhalb eines Mutex<T>
zu halten, wie wir es in Listing 16-12 getan haben. Als Nächstes erstellen wir
10 Threads, indem wir über einen Zahlenbereich iterieren. Wir verwenden
thread::spawn und geben allen Threads den gleichen Closure, der den Zähler in
den Thread verschiebt, eine Sperre auf dem Mutex<T> durch Aufrufen der Methode
lock erwirbt und dann 1 zum Wert im Mutex addiert. Wenn ein Thread die
Ausführung seines Closures beendet hat, verlässt num den Gültigkeitsbereich
und gibt die Sperre frei, sodass ein anderer Thread sie erwerben kann.
Im Haupt-Thread sammeln wir alle JoinHandle. Dann rufen wir analog zu
Listing 16-2 join auf jedem Thread auf, um sicherzustellen, dass alle
Threads beendet sind. An diesem Punkt erhält der Haupt-Thread die Sperre und
gibt das Ergebnis dieses Programms aus.
Wir haben angedeutet, dass sich dieses Beispiel nicht kompilieren lässt. Jetzt wollen wir herausfinden, warum!
$ cargo run
Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
--> src/main.rs:21:29
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..10 {
| -------------- inside of this loop
9 | let handle = thread::spawn(move || {
| ------- value moved into closure here, in previous iteration of loop
...
21 | println!("Result: {}", *counter.lock().unwrap());
| ^^^^^^^ value borrowed here after move
|
help: consider moving the expression out of the loop so it is only moved once
|
8 ~ let mut value = counter.lock();
9 ~ for _ in 0..10 {
10 | let handle = thread::spawn(move || {
11 ~ let mut num = value.unwrap();
|
For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error
Die Fehlermeldung besagt, dass der Wert counter in der vorherigen Iteration
der Schleife verschoben wurde. Rust sagt uns, dass wir das Eigentum an counter
nicht in mehrere Threads verschieben können. Lass uns den Compilerfehler mit
einer Mehrfacheigentums-Methode beheben, die wir in Kapitel 15 besprochen haben.
Mehrfacheigentum mit mehreren Threads
In Kapitel 15 gaben wir einen Wert an mehrere Eigentümer, indem wir den
intelligenten Zeiger Rc<T> verwendet haben, um einen Referenzzählwert zu
erstellen. Lass uns hier das Gleiche tun und sehen, was passiert. Wir packen den
Mutex<T> in Rc<T> in Listing 16-14 ein und klonen Rc<T>, bevor wir das
Eigentum an den Thread übertragen.
Dateiname: src/main.rs
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Rc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Rc::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());
}
Listing 16-14: Versuch, Rc<T> zu verwenden, um
mehreren Threads zu erlauben, den Mutex<T> zu besitzen
Wir kompilieren erneut und bekommen verschiedene Fehler! Der Compiler teilt uns viel mit.
$ cargo run
Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ------------- ^------
| | |
| ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
| | |
| | required by a bound introduced by this call
12 | | let mut num = counter.lock().unwrap();
13 | |
14 | | *num += 1;
15 | | });
| |_________^ `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
|
= help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`
note: required because it's used within this closure
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
--> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:723:1
For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error
Toll, diese Fehlermeldung ist sehr wortreich! Hier ist der wichtige Teil, auf
den wir uns konzentrieren müssen: `Rc<Mutex<i32>>` cannot be sent between threads safely Der Compiler teilt uns auch den Grund dafür mit: Das Trait
(trait) Send ist für Rc<Mutex<i32>> nicht implementiert. Wir werden im
nächsten Abschnitt über das Trait Send sprechen: Es ist eines der Traits, das
sicherstellt, dass die Typen, die wir mit Threads verwenden, für die Verwendung
in nebenläufigen Situationen gedacht sind.
Leider ist es nicht sicher, Rc<T> über verschiedene Threads hinweg gemeinsam
zu nutzen. Wenn Rc<T> den Referenzzähler verwaltet, inkrementiert es den
Zähler bei jedem Aufruf von clone und dekrementiert den Zähler bei jedem
Klon, der aufgeräumt wird. Es werden jedoch keine Nebenläufigkeitsprimitive
verwendet, um sicherzustellen, dass Änderungen am Zähler nicht durch einen
anderen Thread unterbrochen werden können. Dies könnte zu falschen Zählungen
führen – subtile Fehler, die wiederum zu Speicherlecks (memory leaks)
oder zum Aufräumen eines Wertes führen könnten, obwohl wir ihn noch nutzen
wollen. Was wir brauchen, ist ein Typ genau wie Rc<T>, aber einer, der
Änderungen am Referenzzähler auf Thread-sichere (thread-safe) Weise vornimmt.
Atomare Referenzzählung mit Arc<T>
Glücklicherweise ist Arc<T> ein Typ wie Rc<T>, der in nebenläufigen
Situationen sicher zu verwenden ist. Das a steht für atomar, d.h. es
handelt sich um einen atomar-referenzzählenden (atomically reference
counted) Typ. Atomare Typen (atomics) sind eine zusätzliche Art von
Nebenläufigkeitsprimitiven, die wir hier nicht im Detail behandeln werden:
Weitere Einzelheiten findest du in der Standardbibliotheksdokumentation für
std::sync::atomic. An dieser Stelle musst du nur wissen, dass
atomare Typen wie primitive Typen funktionieren, aber sicher über Threads
hinweg gemeinsam genutzt werden können.
Du wirst dich dann vielleicht fragen, warum nicht alle primitiven Typen atomar
sind und warum Standardbibliothekstypen nicht so implementiert sind, dass sie
standardmäßig Arc<T> verwenden. Der Grund dafür ist, dass Thread-Sicherheit
mit Performanzeinbußen verbunden ist, die du nur dann zahlen willst, wenn du
sie wirklich brauchst. Wenn du nur Operationen an Werten innerhalb eines
einzelnen Threads durchführst, kann dein Code schneller laufen, wenn er nicht
die Garantien erzwingen muss, die atomare Typen bieten.
Kehren wir zu unserem Beispiel zurück: Arc<T> und Rc<T> haben die gleiche
API, also reparieren wir unser Programm, indem wir die use-Zeile, den Aufruf
von new und den Aufruf von clone ändern. Der Code in Listing 16-15 wird
schließlich kompilieren und laufen.
Dateiname: src/main.rs
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
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());
}
Listing 16-15: Verwenden von Arc<T>, um den Mutex<T>
einzupacken und das Eigentum mit mehreren Threads zu teilen
Dieser Code gibt Folgendes aus:
Ergebnis: 10
Wir haben es geschafft! Wir zählten von 0 bis 10, was nicht sehr beeindruckend
erscheinen mag, aber wir haben viel über Mutex<T> und Threadsicherheit
gelernt. Du kannst die Struktur dieses Programms auch dazu nutzen,
kompliziertere Operationen durchzuführen als nur einen Zähler zu
inkrementieren. Mit dieser Strategie kannst du eine Berechnung in unabhängige
Teile aufteilen, diese Teile auf Threads verteilen und dann Mutex<T>
verwenden, damit jeder Thread das Endergebnis mit seinem Teil aktualisiert.
Beachte, dass es für einfache numerische Operationen einfachere Typen als
Mutex<T> gibt, die durch das Modul std::sync::atomic der
Standardbibliothek bereitgestellt werden. Diese Typen bieten sicheren,
nebenläufigen und atomaren Zugriff auf primitive Typen. Wir haben uns
entschieden, Mutex<T> mit einem primitiven Typ für dieses Beispiel zu
verwenden, damit wir uns darauf konzentrieren können, wie Mutex<T>
funktioniert.
Vergleich von RefCell<T>/Rc<T> und Mutex<T>/Arc<T>
Du hast vielleicht bemerkt, dass counter unveränderbar (immutable) ist, aber
wir könnten eine veränderbare (mutable) Referenz auf den Wert in seinem
Inneren erhalten; das bedeutet, dass Mutex<T> innere Veränderbarkeit
(interior mutability) bietet, wie es die Cell-Familie tut. Auf gleiche Weise,
wie wir RefCell<T> in Kapitel 15 benutzt haben, um Inhalte innerhalb eines
Rc<T> verändern zu können, benutzen wir Mutex<T>, um Inhalte innerhalb
eines Arc<T> zu verändern.
Ein weiteres zu beachtendes Detail ist, dass Rust dich nicht vor allen Arten von
Logikfehlern schützen kann, wenn du Mutex<T> verwendest. Erinnere dich an
Kapitel 15, dass die Verwendung von Rc<T> mit dem Risiko verbunden ist,
Referenzzyklen zu erzeugen, bei denen sich zwei Rc<T>-Werte gegenseitig
referenzieren und dadurch Speicherlecks verursachen. In ähnlicher Weise ist
Mutex<T> mit dem Risiko verbunden, Deadlocks zu schaffen. Diese treten auf,
wenn eine Operation zwei Ressourcen sperren muss und zwei Threads jeweils eine
der Sperren erworben haben, was dazu führt, dass sie ewig aufeinander warten.
Wenn du an Deadlocks interessiert bist, versuche ein Programm in Rust zu
schreiben, das einen Deadlock hat; dann recherchiere Strategien zur Vermeidung
von Deadlocks mit Mutexes in einer beliebigen Sprache und versuche, sie in Rust
zu implementieren. Die Standardbibliotheks-API-Dokumentation für Mutex<T> und
MutexGuard bietet nützliche Informationen.
Wir runden dieses Kapitel ab, indem wir über die Traits Send und Sync
sprechen und wie wir sie mit benutzerdefinierten Typen verwenden können.