Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Programmcode mit Threads gleichzeitig ausführen

In den meisten aktuellen Betriebssystemen wird der Code eines ausgeführten Programms in einem Prozess ausgeführt und das Betriebssystem verwaltet mehrere Prozesse gleichzeitig. Innerhalb eines Programms kannst du auch unabhängige Teile haben, die gleichzeitig laufen. Die Funktionalitäten, die diese unabhängigen Teile ausführen, werden Threads (Stränge) genannt. Ein Webserver könnte beispielsweise mehrere Threads haben, damit er auf mehrere Anfragen gleichzeitig reagieren kann.

Das Aufteilen der Berechnung in deinem Programm in mehrere Threads, um mehrere Aufgaben gleichzeitig auszuführen, kann die Performanz erhöhen, aber es erhöht auch die Komplexität. Da Threads gleichzeitig laufen können, gibt es keine Garantie für die Reihenfolge, in der Teile deines Codes in verschiedenen Threads ausgeführt werden. Dies kann zu Problemen führen wie:

  • Race Conditions, bei denen Threads auf Daten oder Ressourcen in einer inkonsistenten Reihenfolge zugreifen.
  • Deadlocks, bei denen zwei Threads auf den jeweils anderen warten, sodass beide Threads nicht fortgesetzt werden können.
  • Fehler, die nur in bestimmten Situationen auftreten und schwer zu reproduzieren und zu beheben sind.

Rust versucht, die negativen Auswirkungen bei der Verwendung von Threads zu mildern, aber die Programmierung in einem multi-threaded Kontext erfordert immer noch sorgfältige Überlegungen und benötigt eine andere Code-Struktur als bei Programmen, die in einem einzigen Thread laufen.

Programmiersprachen implementieren Threads auf verschiedene Weise, und viele Betriebssysteme bieten eine API, die die Sprache aufrufen kann, um neue Threads zu erstellen. Die Rust-Standardbibliothek verwendet ein 1:1-Modell der Thread-Implementierung, bei dem ein Programm einen Betriebssystem-Thread für einen Sprach-Thread verwendet. Es gibt Crates, die andere Thread-Modelle implementieren, die andere Kompromisse als das 1:1-Modell eingehen. (Das async-System von Rust, das wir uns im nächsten Kapitel ansehen werden, bietet ebenfalls einen anderen Ansatz der Nebenläufigkeit.)

Erstellen eines neuen Threads mit spawn

Um einen neuen Thread zu erstellen, rufen wir die Funktion thread::spawn auf und übergeben ihr einen Closure (wir haben in Kapitel 13 über Closures gesprochen), der den Code enthält, den wir im neuen Thread ausführen wollen. Das Beispiel in Listing 16-1 gibt einen Text im Haupt-Thread und anderen Text im neuen Thread aus:

Dateiname: src/main.rs

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("Hallo Zahl {i} aus dem erzeugten Thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("Hallo Zahl {i} aus dem Haupt-Thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

Listing 16-1: Erstellen eines neuen Threads, um einen Text auszugeben, während der Haupt-Thread einen anderen Text ausgibt

Beachte, dass bei der Beendigung des Haupt-Threads eines Rust-Programms alle erzeugten Threads beendet werden, unabhängig davon, ob sie zu Ende gelaufen sind oder nicht. Die Ausgabe dieses Programms kann jedes Mal ein wenig anders sein, aber sie wird in etwa wie folgt aussehen:

Hallo Zahl 1 aus dem Haupt-Thread!
Hallo Zahl 1 aus dem erzeugten Thread!
Hallo Zahl 2 aus dem Haupt-Thread!
Hallo Zahl 2 aus dem erzeugten Thread!
Hallo Zahl 3 aus dem Haupt-Thread!
Hallo Zahl 3 aus dem erzeugten Thread!
Hallo Zahl 4 aus dem Haupt-Thread!
Hallo Zahl 4 aus dem erzeugten Thread!
Hallo Zahl 5 aus dem erzeugten Thread!

Aufrufe von thread::sleep zwingen einen Thread, seine Ausführung für eine kurze Zeit anzuhalten, sodass ein anderer Thread laufen kann. Die Threads werden sich wahrscheinlich abwechseln, aber das ist nicht garantiert: Es hängt davon ab, wie dein Betriebssystem die Threads organisiert (schedule). In diesem Lauf hat der Haupt-Thread zuerst etwas ausgegeben, obwohl sich die Ausgabeanweisung des erzeugten Threads weiter oben im Code befindet. Und obwohl wir dem erzeugten Thread gesagt haben, er solle solange etwas ausgeben, bis i den Wert 9 hat, kam er nur bis 5, als sich der Haupt-Thread beendet hat.

Wenn du diesen Code ausführst und nur Ausgaben aus dem Haupt-Thread siehst oder keine Überschneidungen feststellst, versuche, die Zahlen in den Bereichen zu erhöhen, um dem Betriebssystem mehr Gelegenheit zu geben, zwischen den Threads zu wechseln.

Warten auf das Ende aller Threads

Der Code in Listing 16-1 beendet nicht nur den erzeugten Thread meist vorzeitig, weil der Haupt-Thread endet, sondern weil es keine Garantie für die Reihenfolge gibt, in der Threads laufen. Wir können auch nicht garantieren, dass der erzeugte Thread überhaupt zum Laufen kommt!

Wir können das Problem, dass der erzeugte Thread nicht läuft oder vorzeitig beendet wird, beheben, indem wir den Rückgabewert von thread::spawn in einer Variable speichern. Der Rückgabetyp von thread::spawn ist JoinHandle<T>. Ein JoinHandle<T> ist ein besitzender (owned) Wert, der, wenn wir die Methode join darauf aufrufen, darauf wartet, bis sich sein Thread beendet. Listing 16-2 zeigt, wie der JoinHandle<T> des Threads, den wir in Listing 16-1 erstellt haben, verwendet und wie join aufgerufen wird, um sicherzustellen, dass der erzeugte Thread beendet wird, bevor main endet:

Dateiname: src/main.rs

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("Hallo Zahl {i} aus dem erzeugten Thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("Hallo Zahl {i} aus dem Haupt-Thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

Listing 16-2: Speichern des JoinHandle<T> von thread::spawn, um zu garantieren, dass der Thread bis zum Ende ausgeführt wird

Aufrufen von join auf JoinHandle blockiert den gerade laufenden Thread, bis der durch JoinHandle repräsentierte Thread beendet ist. Blockieren eines Threads bedeutet, dass der Thread daran gehindert wird, Arbeit auszuführen oder sich zu beenden. Da wir den Aufruf von join nach der for-Schleife im Haupt-Thread gesetzt haben, sollte das Ausführen von Listing 16-2 eine Ausgabe wie folgt erzeugen:

Hallo Zahl 1 aus dem Haupt-Thread!
Hallo Zahl 2 aus dem Haupt-Thread!
Hallo Zahl 1 aus dem erzeugten Thread!
Hallo Zahl 3 aus dem Haupt-Thread!
Hallo Zahl 2 aus dem erzeugten Thread!
Hallo Zahl 4 aus dem Haupt-Thread!
Hallo Zahl 3 aus dem erzeugten Thread!
Hallo Zahl 4 aus dem erzeugten Thread!
Hallo Zahl 5 aus dem erzeugten Thread!
Hallo Zahl 6 aus dem erzeugten Thread!
Hallo Zahl 7 aus dem erzeugten Thread!
Hallo Zahl 8 aus dem erzeugten Thread!
Hallo Zahl 9 aus dem erzeugten Thread!

Die beiden Threads setzen die Ausführung abwechselnd fort, aber der Haupt-Thread wartet wegen des Aufrufs von handle.join() und endet nicht, bis der erzeugte Thread beendet ist.

Aber lass uns sehen, was passiert, wenn wir stattdessen handle.join() vor die for-Schleife in main schieben, etwa so:

Dateiname: src/main.rs

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("Hallo Zahl {i} aus dem erzeugten Thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("Hallo Zahl {i} aus dem Haupt-Thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

Der Haupt-Thread wartet auf das Ende des erzeugten Threads und führt dann seine for-Schleife aus, sodass die Ausgabe nicht mehr überlappend ist, wie hier gezeigt:

Hallo Zahl 1 aus dem erzeugten Thread!
Hallo Zahl 2 aus dem erzeugten Thread!
Hallo Zahl 3 aus dem erzeugten Thread!
Hallo Zahl 4 aus dem erzeugten Thread!
Hallo Zahl 5 aus dem erzeugten Thread!
Hallo Zahl 6 aus dem erzeugten Thread!
Hallo Zahl 7 aus dem erzeugten Thread!
Hallo Zahl 8 aus dem erzeugten Thread!
Hallo Zahl 9 aus dem erzeugten Thread!
Hallo Zahl 1 aus dem Haupt-Thread!
Hallo Zahl 2 aus dem Haupt-Thread!
Hallo Zahl 3 aus dem Haupt-Thread!
Hallo Zahl 4 aus dem Haupt-Thread!

Kleine Details, z.B. wo join aufgerufen wird, können beeinflussen, ob deine Threads zur gleichen Zeit laufen oder nicht.

Verwenden von move-Closures mit Threads

Wir werden oft das Schlüsselwort move mit Closures verwenden, die an thread::spawn übergeben werden, weil der Closure dann das Eigentum an den Werten, die sie benutzt, von der Umgebung übernimmt und damit das Eigentum an diesen Werten von einem Thread auf einen anderen überträgt. In „Erfassen von Referenzen oder Verschieben des Eigentums“ in Kapitel 13 haben wir move im Zusammenhang mit Closures besprochen. Jetzt werden wir uns mehr auf die Interaktion zwischen move und thread::spawn konzentrieren.

Beachte in Listing 16-1, dass der Closure, den wir an thread::spawn übergeben, keine Argumente erfordert: Wir verwenden keine Daten aus dem Haupt-Thread im Code des erzeugten Threads. Um Daten aus dem Haupt-Thread im erzeugten Thread zu verwenden, muss der Closure des erzeugten Threads die benötigten Werte erfassen. Listing 16-3 zeigt einen Versuch, einen Vektor im Haupt-Thread zu erstellen und ihn im erzeugten Thread zu verwenden. Dies wird jedoch noch nicht funktionieren, wie du gleich sehen wirst.

Dateiname: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Hier ist ein Vektor: {v:?}");
    });

    handle.join().unwrap();
}

Listing 16-3: Versuch, einen im Haupt-Thread erzeugten Vektor in einem anderen Thread zu verwenden

Der Closure verwendet v, sodass v Teil der Umgebung des Closures wird. Da thread::spawn diesen Closure in einem neuen Thread ausführt, sollten wir in der Lage sein, auf v innerhalb dieses neuen Threads zuzugreifen. Aber wenn wir dieses Beispiel kompilieren, erhalten wir den folgenden Fehler:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Hier ist ein Vektor: {v:?}");
  |                                         - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Hier ist ein Vektor: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `playground` (bin "playground") due to 1 previous error

Rust folgert, wie v zu erfassen ist, und weil println! nur eine Referenz auf v benötigt, versucht der Closure, v auszuleihen. Es gibt jedoch ein Problem: Rust kann nicht sagen, wie lange der erzeugte Thread laufen wird, sodass es nicht weiß, ob die Referenz auf v immer gültig sein wird.

Listing 16-4 zeigt ein Szenario, das eine Referenz auf v hat, die eher nicht gültig ist:

Dateiname: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Hier ist ein Vektor: {v:?}");
    });

    drop(v); // Oh nein!

    handle.join().unwrap();
}

Listing 16-4: Ein Thread mit einem Closure, der versucht, eine Referenz auf v vom Haupt-Thread zu erfassen, der v aufräumt

Wenn Rust uns erlauben würde, diesen Code auszuführen, bestünde die Möglichkeit, dass der erzeugte Thread sofort in den Hintergrund gestellt wird, ohne überhaupt zu laufen. Der erzeugte Thread hat eine Referenz auf v im Inneren, aber der Haupt-Thread räumt v sofort auf, indem er die Funktion drop benutzt, die wir in Kapitel 15 besprochen haben. Wenn der erzeugte Thread dann mit der Ausführung beginnt, ist v nicht mehr gültig, sodass eine Referenz darauf ebenfalls ungültig ist. Oh nein!

Um den Compilerfehler in Listing 16-3 zu beheben, können wir die Hinweise der Fehlermeldung verwenden:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

Indem wir vor dem Closure das Schlüsselwort move hinzufügen, zwingen wir den Closure dazu, das Eigentum an den Werten zu übernehmen, die er benutzt, anstatt zuzulassen, dass Rust daraus ableitet, dass er sich die Werte ausleihen sollte. Die in Listing 16-5 gezeigte Änderung an Listing 16-3 wird wie von uns beabsichtigt kompilieren und ausgeführt.

Dateiname: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Hier ist ein Vektor: {v:?}");
    });

    handle.join().unwrap();
}

Listing 16-5: Durch Verwenden des Schlüsselwortes move zwingen wir den Closure, das Eigentum an den von ihm verwendeten Werten zu übernehmen

Wir könnten versuchen, den Code in Listing 16-4 auf dieselbe Weise zu reparieren, wo der Haupt-Thread drop aufruft, während wir einen move-Closure verwenden. Diese Lösung wird jedoch nicht funktionieren, weil das, was Listing 16-4 versucht, aus einem anderen Grund nicht erlaubt ist. Wenn wir dem Closure move hinzufügen, würden wir v in die Umgebung des Closures verschieben, und wir könnten im Haupt-Thread nicht mehr drop darauf aufrufen. Wir würden stattdessen diesen Compilerfehler erhalten:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
 4 |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
 5 |
 6 |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
 7 |         println!("Here's a vector: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move
   |
help: consider cloning the value before moving it into the closure
   |
 6 ~     let value = v.clone();
 7 ~     let handle = thread::spawn(move || {
 8 ~         println!("Here's a vector: {value:?}");
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Die Eigentumsregeln von Rust haben uns wieder einmal gerettet! Wir haben einen Fehler im Code in Listing 16-3 erhalten, weil Rust konservativ war und nur v für den Thread auslieh, was bedeutete, dass der Haupt-Thread theoretisch die Referenz des erzeugten Threads ungültig machen konnte. Indem wir Rust anweisen, das Eigentum an v in den erzeugten Thread zu verschieben, garantieren wir Rust, dass der Haupt-Thread v nicht mehr benutzen wird. Wenn wir Listing 16-4 auf die gleiche Weise ändern, verletzen wir die Eigentumsregeln, wenn wir versuchen, v im Haupt-Thread zu benutzen. Das Schlüsselwort move setzt Rusts konservativen Borrowing-Standard außer Kraft; es lässt uns nicht gegen die Eigentumsregeln verstoßen.

Nachdem wir uns nun damit beschäftigt haben, was Threads sind und welche Methoden die Thread-API bietet, wollen wir uns nun einige Situationen ansehen, in denen wir Threads verwenden können.