Gemeinsamer Zustand in der Concurrency in Rust

RustRustBeginner
Jetzt üben

This tutorial is from open-source community. Access the source code

💡 Dieser Artikel wurde von AI-Assistenten übersetzt. Um die englische Version anzuzeigen, können Sie hier klicken

Einführung

Willkommen zu Shared-State Concurrency. Dieser Lab ist Teil des Rust Buchs. Du kannst deine Rust-Fähigkeiten in LabEx üben.

In diesem Lab untersuchen wir das Konzept der Shared-Memory-Concurrency und warum Unterstützer der Message-Passing-Methode Vorsicht walten lassen.

Shared-State Concurrency

Message passing ist ein guter Weg, um Concurrency zu handhaben, aber es ist nicht der einzige Weg. Ein anderer Ansatz wäre, dass mehrere Threads auf die gleiche geteilte Daten zugreifen. Betrachten Sie erneut diesen Teil des Slogans aus der Go-Sprachen-Dokumentation: "Kommunizieren Sie nicht, indem Sie Speicher teilen."

Wie würde das Kommandieren durch das Teilen von Speicher aussehen? Darüber hinaus warum würden Unterstützer der Message-Passing-Methode Vorsicht walten lassen, nicht das Speicher teilen zu verwenden?

In gewisser Weise sind Kanäle in jeder Programmiersprache ähnlich wie die einzelne Eigentumsverwaltung, da Sie, nachdem Sie einen Wert über einen Kanal übertragen haben, diesen Wert nicht mehr verwenden sollten. Shared-Memory-Concurrency ist wie die Mehrfach-Eigentumsverwaltung: mehrere Threads können gleichzeitig auf die gleiche Speicheradresse zugreifen. Wie Sie im Kapitel 15 gesehen haben, wo Smart-Pointer die Mehrfach-Eigentumsverwaltung möglich machten, kann die Mehrfach-Eigentumsverwaltung die Komplexität erhöhen, da diese verschiedenen Besitzer verwaltet werden müssen. Rusts Typsystem und die Eigentumsregeln helfen erheblich, um diese Verwaltung richtig zu erledigen. Als Beispiel betrachten wir Mutexe, eines der häufigeren Concurrency-Primitive für Shared Memory.

Verwendung von Mutexen, um Zugang zu Daten zu ermöglichen, nur von einem Thread zur selben Zeit

Mutex ist die Abkürzung für mutual exclusion (gegenseitige Ausschließung), da ein Mutex nur einem Thread erlaubt, zu einem bestimmten Zeitpunkt auf einige Daten zuzugreifen. Um auf die Daten in einem Mutex zuzugreifen, muss ein Thread zunächst signalisieren, dass er den Zugang wünscht, indem er den Lock (Sperre) des Mutexes anfordert. Der Lock ist eine Datenstruktur, die Teil des Mutexes ist und verfolgt, wer derzeit ausschließlich auf die Daten zugreifen kann. Daher wird der Mutex als bewachend die Daten beschrieben, die er über das Sperrsystem hält.

Mutexe haben einen Ruf, schwierig zu verwenden, weil man zwei Regeln beachten muss:

  1. Man muss versuchen, den Lock zu erwerben, bevor man die Daten verwendet.
  2. Wenn man mit den Daten fertig ist, die der Mutex bewacht, muss man die Daten entsperren, damit andere Threads den Lock erwerben können.

Als realitätsnahes Beispiel für einen Mutex kann man sich eine Podiumsrunde auf einer Konferenz vorstellen, bei der es nur ein Mikrofon gibt. Bevor ein Podiumsredner sprechen kann, muss er fragen oder signalisieren, dass er das Mikrofon verwenden möchte. Wenn er das Mikrofon erhält, kann er so lange sprechen, wie er möchte, und gibt dann das Mikrofon an den nächsten Podiumsredner weiter, der sprechen möchte. Wenn ein Podiumsredner vergisst, das Mikrofon abzugeben, wenn er damit fertig ist, kann niemand else sprechen. Wenn die Verwaltung des geteilten Mikrofons fehlschlägt, funktioniert die Podiumsrunde nicht wie geplant!

Die Verwaltung von Mutexen kann extrem schwierig sein, um richtig zu erledigen, weshalb so viele Leute von Kanälen begeistert sind. Dank des Rust-Typsystems und der Eigentumsregeln kann man jedoch nicht falsch sperren und entsperren.

Die API von Mutex<T>

Als Beispiel für die Verwendung eines Mutex starten wir mit der Verwendung eines Mutex in einem ein-threadigen Kontext, wie in Listing 16-12 gezeigt.

Dateiname: src/main.rs

use std::sync::Mutex;

fn main() {
  1 let m = Mutex::new(5);

    {
      2 let mut num = m.lock().unwrap();
      3 *num = 6;
  4 }

  5 println!("m = {:?}", m);
}

Listing 16-12: Erkundung der API von Mutex<T> in einem ein-threadigen Kontext zur Vereinfachung

Wie bei vielen Typen erstellen wir einen Mutex<T> mit der assoziierten Funktion new [1]. Um auf die Daten innerhalb des Mutex zuzugreifen, verwenden wir die lock-Methode, um den Lock zu erwerben [2]. Dieser Aufruf blockiert den aktuellen Thread, sodass er keine Arbeit machen kann, bis es an unserem Zug ist, den Lock zu erwerben.

Der Aufruf von lock würde fehlschlagen, wenn ein anderer Thread, der den Lock hält, in Panik gerät. In diesem Fall würde niemals jemand den Lock erhalten, daher haben wir entschieden, unwrap aufzurufen und diesen Thread in Panik zu versetzen, wenn wir in dieser Situation sind.

Nachdem wir den Lock erworben haben, können wir den Rückgabewert, in diesem Fall num genannt, als mutablen Verweis auf die Daten innerhalb betrachten. Das Typsystem stellt sicher, dass wir einen Lock erwerben, bevor wir den Wert in m verwenden. Der Typ von m ist Mutex<i32>, nicht i32, daher müssen wir lock aufrufen, um den i32-Wert verwenden zu können. Wir können nicht vergessen; das Typsystem wird uns nicht zugelassen, auf das innere i32 zuzugreifen, anders als so.

Wie Sie vermuten könnten, ist Mutex<T> ein Smart-Pointer. Genauer gesagt gibt der Aufruf von lock einen Smart-Pointer namens MutexGuard zurück, der in einem LockResult verpackt ist, das wir mit dem Aufruf von unwrap behandelt haben. Der Smart-Pointer MutexGuard implementiert Deref, um auf unsere inneren Daten zu verweisen; der Smart-Pointer hat auch eine Drop-Implementierung, die den Lock automatisch freigibt, wenn ein MutexGuard außer Gültigkeitsbereich geht, was am Ende des inneren Bereichs passiert [4]. Dadurch riskieren wir nicht, den Lock zu vergessen und den Mutex für andere Threads zu blockieren, da die Lockfreigabe automatisch erfolgt.

Nachdem wir den Lock fallen lassen, können wir den Mutex-Wert ausgeben und sehen, dass wir das innere i32 auf 6 ändern konnten [5].

Teilen eines Mutex<T> Zwischen mehreren Threads

Lassen Sie uns nun versuchen, einen Wert zwischen mehreren Threads mithilfe von 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 nutzen, um mehr über die Verwendung von Mutex<T> und wie Rust uns hilft, es richtig zu verwenden, zu lernen.

Dateiname: src/main.rs

use std::sync::Mutex;
use std::thread;

fn main() {
  1 let counter = Mutex::new(0);
    let mut handles = vec![];

  2 for _ in 0..10 {
      3 let handle = thread::spawn(move || {
          4 let mut num = counter.lock().unwrap();

          5 *num += 1;
        });
      6 handles.push(handle);
    }

    for handle in handles {
      7 handle.join().unwrap();
    }

  8 println!("Result: {}", *counter.lock().unwrap());
}

Listing 16-13: Zehn Threads, die jeweils einen von einem Mutex<T> bewachten Zähler um 1 erhöhen

Wir erstellen eine counter-Variable, um ein i32 innerhalb eines Mutex<T> zu speichern [1], wie wir es in Listing 16-12 getan haben. Als nächstes erstellen wir 10 Threads, indem wir über einen Zahlenbereich iterieren [2]. Wir verwenden thread::spawn und geben allen Threads die gleiche Closure: eine, die den Zähler in den Thread verschiebt [3], einen Lock auf dem Mutex<T> erlangt, indem die lock-Methode aufgerufen wird [4], und dann 1 zum Wert im Mutex hinzufügt [5]. Wenn ein Thread mit der Ausführung seiner Closure fertig ist, wird num außer Gültigkeitsbereich gehen und den Lock freigeben, sodass ein anderer Thread ihn erwerben kann.

Im Hauptthread sammeln wir alle Join-Handles [6]. Dann, wie wir es in Listing 16-2 getan haben, rufen wir join auf jedem Handle auf, um sicherzustellen, dass alle Threads fertig sind [7]. Zu diesem Zeitpunkt wird der Hauptthread den Lock erwerben und das Ergebnis dieses Programms ausgeben [8].

Wir haben angedeutet, dass dieses Beispiel nicht kompilieren würde. Lassen Sie uns nun herausfinden, warum!

error[E0382]: use of moved value: `counter`
  --> src/main.rs:9:36
   |
5  |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `Mutex<i32>`, which
does not implement the `Copy` trait
...
9  |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^ value moved into closure here,
in previous iteration of loop
10 |             let mut num = counter.lock().unwrap();
   |                           ------- use occurs due to use in closure

Die Fehlermeldung besagt, dass der counter-Wert in der vorherigen Iteration der Schleife verschoben wurde. Rust sagt uns, dass wir die Eigentumsgewalt des Locks counter nicht in mehrere Threads verschieben können. Lassen Sie uns den Compilerfehler mit der Mehrfach-Eigentumsmethode beheben, die wir im Kapitel 15 diskutiert haben.

Mehrfach-Eigentum mit mehreren Threads

Im Kapitel 15 haben wir einem Wert mehrere Besitzer gegeben, indem wir den Smart-Pointer Rc<T> verwendet haben, um einen referenzzählenden Wert zu erstellen. Lassen Sie uns das hier wieder tun und sehen, was passiert. Wir werden das Mutex<T> in Rc<T> in Listing 16-14 einwickeln und das Rc<T> klonen, bevor wir die Eigentumsgewalt 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!("Result: {}", *counter.lock().unwrap());
}

Listing 16-14: Versuch, Rc<T> zu verwenden, um mehreren Threads das Mutex<T> zu geben

Wir kompilieren erneut und erhalten... unterschiedliche Fehler! Der Compiler lehrt uns viel.

error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely 1
   --> src/main.rs:11:22
    |
11  |           let handle = thread::spawn(move || {
    |  ______________________^^^^^^^^^^^^^_-
    | |                      |
    | |                      `Rc<Mutex<i32>>` cannot be sent between threads
safely
12  | |             let mut num = counter.lock().unwrap();
13  | |
14  | |             *num += 1;
15  | |         });
    | |_________- within this `[closure@src/main.rs:11:36: 15:10]`
    |
= help: within `[closure@src/main.rs:11:36: 15:10]`, the trait `Send` is not
implemented for `Rc<Mutex<i32>>` 2
    = note: required because it appears within the type
`[closure@src/main.rs:11:36: 15:10]`
note: required by a bound in `spawn`

Wow, diese Fehlermeldung ist sehr wortreich! Hier ist der wichtige Teil, auf den Sie sich konzentrieren sollten: Rc<Mutex<i32>>` cannot be sent between threads safely` [1]. Der Compiler sagt uns auch den Grund dafür: `the trait `Send` is not implemented for `Rc<Mutex<i32>> [2]. Wir werden im nächsten Abschnitt über Send sprechen: es ist eines der Traits, die gewährleistet, dass die Typen, die wir mit Threads verwenden, für die Verwendung in konkurrierenden Situationen geeignet sind.

Leider ist es nicht sicher, Rc<T> über Threads hinweg zu teilen. Wenn Rc<T> die Referenzzählung verwaltet, erhöht es die Anzahl für jeden Aufruf von clone und verringert die Anzahl, wenn jeder Klon fallen gelassen wird. Aber es verwendet keine konkurrenzspezifischen Primitiven, um sicherzustellen, dass Änderungen an der Anzahl nicht von einem anderen Thread unterbrochen werden können. Dies könnte zu falschen Zählungen führen - subtilen Fehlern, die wiederum zu Speicherlecks oder einem Wert führen können, der vor dem Ende der Verwendung fallen gelassen wird. Was wir brauchen, ist ein Typ, der genau wie Rc<T> ist, aber der Änderungen an der Referenzzählung auf einem threadsicheren Weg vornimmt.

Atomare Referenzzählung mit Arc<T>

Glücklicherweise ist Arc<T> ein Typ wie Rc<T>, der in konkurrierenden Situationen sicher zu verwenden ist. Der Buchstabe a steht für atomar, was bedeutet, dass es ein atomar referenzzählender Typ ist. Atomare Datentypen sind eine weitere Art von konkurrenzspezifischen Primitiven, die wir hier nicht im Detail behandeln werden: Siehe die Standardbibliothek-Dokumentation für std::sync::atomic für weitere Details. An diesem Punkt müssen Sie nur wissen, dass atomare Datentypen wie primitive Datentypen funktionieren, aber sicher über Threads hinweg zu teilen sind.

Sie könnten sich dann fragen, warum nicht alle primitiven Datentypen atomar sind und warum Standardbibliothekstypen standardmäßig nicht so implementiert sind, dass sie Arc<T> verwenden. Der Grund ist, dass die Threadsicherheit mit einem Leistungsverlust verbunden ist, den Sie nur zahlen möchten, wenn Sie ihn wirklich benötigen. Wenn Sie nur Operationen auf Werten innerhalb eines einzelnen Threads ausführen, kann Ihr Code schneller laufen, wenn er nicht die Garantien erfüllen muss, die atomare Datentypen bieten.

Lassen Sie uns zu unserem Beispiel zurückkehren: Arc<T> und Rc<T> haben die gleiche API, daher beheben 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 ausgeführt werden.

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!("Result: {}", *counter.lock().unwrap());
}

Listing 16-15: Verwenden eines Arc<T> zum Umhüllen des Mutex<T>, um die Eigentumsgewalt über mehrere Threads hinweg zu teilen

Dieser Code wird Folgendes ausgeben:

Result: 10

Wir haben es geschafft! Wir haben von 0 bis 10 gezählt, was vielleicht nicht sehr beeindruckend klingt, aber es hat uns viel über Mutex<T> und Threadsicherheit gelehrt. Sie könnten auch die Struktur dieses Programms verwenden, um komplexere Operationen durchzuführen, als nur einen Zähler zu erhöhen. Mit dieser Strategie können Sie eine Berechnung in unabhängige Teile unterteilen, diese Teile über Threads verteilen und dann einen Mutex<T> verwenden, um jeden Thread seinen Teil mit dem Endresultat zu aktualisieren.

Beachten Sie, dass wenn Sie einfache numerische Operationen durchführen, es Datentypen gibt, die einfacher sind als Mutex<T>-Typen, die vom std::sync::atomic-Modul der Standardbibliothek bereitgestellt werden. Diese Typen bieten sicheren, konkurrierenden, atomaren Zugang zu primitiven Datentypen. Wir haben für dieses Beispiel einen Mutex<T> mit einem primitiven Datentyp verwendet, damit wir uns auf die Funktionsweise von Mutex<T> konzentrieren konnten.

Ähnlichkeiten zwischen RefCell<T>/Rc<T> und Mutex<T>/Arc<T>

Sie haben vielleicht bemerkt, dass counter unveränderlich ist, aber wir konnten auf einen mutablen Verweis auf den darin enthaltenen Wert zugreifen; dies bedeutet, dass Mutex<T> wie die Cell-Familie innere Veränderbarkeit bietet. Auf die gleiche Weise wie wir in Kapitel 15 RefCell<T> verwendet haben, um die Inhalte innerhalb eines Rc<T> zu verändern, verwenden wir Mutex<T>, um die Inhalte innerhalb eines Arc<T> zu verändern.

Ein weiterer Detail, das zu beachten ist, ist, dass Rust Sie nicht vor allen Arten von logischen Fehlern schützen kann, wenn Sie Mutex<T> verwenden. Denken Sie sich zurück an Kapitel 15, bei dem das Verwenden von Rc<T> mit dem Risiko verbunden war, Referenzzirkel zu erzeugen, bei denen zwei Rc<T>-Werte aufeinander verweisen und dadurch Speicherlecks verursachen. Ähnlich hat Mutex<T> das Risiko, Deadlocks zu erzeugen. Dies tritt auf, wenn eine Operation zwei Ressourcen sperren muss und zwei Threads jeweils einen der Locks erworben haben, was dazu führt, dass sie sich für immer aufeinander warten. Wenn Sie an Deadlocks interessiert sind, versuchen Sie, ein Rust-Programm zu erstellen, das einen Deadlock hat; dann recherchieren Sie Deadlock-Minderungsstrategien für Mutexe in jeder Sprache und versuchen Sie, sie in Rust umzusetzen. Die Standardbibliothek-APIDokumentation für Mutex<T> und MutexGuard bietet nützliche Informationen.

Wir werden dieses Kapitel abschließen, indem wir über die Send- und Sync-Traits und wie wir sie mit benutzerdefinierten Typen verwenden können, sprechen.

Zusammenfassung

Herzlichen Glückwunsch! Sie haben das Shared-State Concurrency-Labor abgeschlossen. Sie können in LabEx weitere Labore ausprobieren, um Ihre Fähigkeiten zu verbessern.