Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Alles zusammenfügen: Futures, Aufgaben und Threads

Wie wir in Kapitel 16 gesehen haben, bieten Threads einen Ansatz für Nebenläufigkeit (concurrency). In diesem Kapitel haben wir einen anderen Ansatz für Nebenläufigkeit gesehen: Verwenden von asynchronem Code mit Futures und Streams. Wenn du dich fragst, wann du eine Methode der anderen vorziehen solltest, lautet die Antwort: Es kommt darauf an! Und in vielen Fällen ist die Wahl nicht Threads oder asynchroner Code, sondern eher Threads und asynchroner Code.

Viele Betriebssysteme bieten schon seit Jahrzehnten Thread-basierte Nebenläufigkeitsmodelle an, und viele Programmiersprachen unterstützen diese Modelle daher. Diese Modelle sind jedoch nicht frei von Kompromissen. Auf vielen Betriebssystemen wird für jeden Thread ein beträchtlicher Teil an Arbeitsspeicher verbraucht. Threads sind auch nur dann eine Option, wenn dein Betriebssystem und deine Hardware sie unterstützen. Im Gegensatz zu herkömmlichen Desktop- und Mobilcomputern haben einige eingebettete Systeme überhaupt kein Betriebssystem, sodass sie auch keine Threads haben.

Das asynchrone Modell bietet eine andere – und letztlich ergänzende – Reihe von Kompromissen. Im asynchronen Modell benötigen nebenläufige Vorgänge keine eigenen Threads. Stattdessen können sie in Aufgaben laufen, so wie wir trpl::spawn_task verwendet haben, um die Arbeit von einer synchronen Funktion im Abschnitt „Streams“ zu starten. Eine Aufgabe ähnelt einem Thread, wird aber nicht vom Betriebssystem, sondern auf Bibliotheksebene verwaltet: der Laufzeitumgebung.

Es gibt einen Grund dafür, dass die APIs zum Erzeugen von Threads und zum Erzeugen von Aufgaben so ähnlich sind. Threads dienen als Grenze für Gruppen von synchronen Operationen; Nebenläufigkeit ist zwischen Threads möglich. Aufgaben fungieren als Grenze für Gruppen von asynchronen Vorgängen; Nebenläufigkeit ist sowohl zwischen als auch innerhalb von Aufgaben möglich, da eine Aufgabe in ihrem Rumpf zwischen Futures wechseln kann. Schließlich sind Futures die granularste Einheit der Nebenläufigkeit in Rust, und jedes Future kann einen Baum von anderen Futures darstellen. Die Laufzeitumgebung – genauer gesagt ihr Executor – verwaltet Aufgaben, und Aufgaben verwalten Futures. In dieser Hinsicht ähneln Aufgaben leichtgewichtigen, von der Laufzeitumgebung verwalteten Threads mit zusätzlichen Fähigkeiten, die sich daraus ergeben, dass sie von der Laufzeitumgebung und nicht vom Betriebssystem verwaltet werden.

Das bedeutet nicht, dass asynchrone Aufgaben immer besser sind als Threads (oder umgekehrt). Nebenläufigkeit mit Threads ist in gewisser Weise ein einfacheres Programmiermodell als Nebenläufigkeit mit async. Das kann eine Stärke und eine Schwäche sein. Threads sind eine Art „Feuern und Vergessen“; sie haben kein natives Äquivalent zu einem Future, also laufen sie einfach bis zum Ende, ohne Unterbrechung, außer durch das Betriebssystem selbst.

Und es stellt sich heraus, dass Threads und Aufgaben oft sehr gut zusammenarbeiten, weil Aufgaben (zumindest in einigen Laufzeitumgebungen) zwischen Threads verschoben werden können. Unter der Haube ist die Laufzeitumgebung, die wir verwenden – einschließlich der Funktionen spawn_blocking und spawn_task – standardmäßig multi-threaded! Viele Laufzeitumgebungen verwenden einen Ansatz namens Work Stealing, um Aufgaben transparent zwischen Threads zu verschieben, je nachdem, wie die Threads gerade ausgelastet sind, um die Gesamtleistung des Systems zu verbessern. Dieser Ansatz erfordert eigentlich Threads und Aufgaben, und damit Futures.

Wenn du überlegst, welche Methode du wann anwenden solltest, beachte diese Daumenregeln:

  • Wenn die Arbeit sehr parallelisierbar ist (d.h. CPU-gebunden), z.B. bei der Verarbeitung einer Menge von Daten, bei der jeder Teil separat verarbeitet werden kann, sind Threads die bessere Wahl.
  • Wenn es sich um sehr nebenläufige Arbeit handelt (d.h. E/A-gebunden), wie die Bearbeitung von Nachrichten aus einer Reihe von verschiedenen Quellen, die in unterschiedlichen Intervallen oder mit unterschiedlicher Geschwindigkeit eintreffen können, ist asynchroner Code die bessere Wahl.

Und wenn du Parallelität und Nebenläufigkeit benötigst, musst du dich nicht zwischen Threads und asynchronem Code entscheiden. Du kannst beide zusammen verwenden, wobei jede der beiden die Aufgabe übernimmt, für die sie am besten geeignet ist. Listing 17-25 zeigt zum Beispiel ein gängiges Beispiel für dieses Zusammenspiel in echtem Rust-Code.

Dateiname: src/main.rs

use std::{thread, time::Duration};

fn main() {
    let (tx, mut rx) = trpl::channel();

    thread::spawn(move || {
        for i in 1..11 {
            tx.send(i).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    trpl::block_on(async {
        while let Some(message) = rx.recv().await {
            println!("{message}");
        }
    });
}

Listing 17-25: Senden von Nachrichten mit blockierendem Code in einem Thread und Warten auf die Nachrichten in einem asynchronen Block

Wir beginnen mit der Erstellung eines asynchronen Kanals. Dann legen wir einen Thread an, der für die Senderseite des Kanals zuständig ist, indem wir das Schlüsselwort move verwenden. Innerhalb des Threads senden wir die Zahlen 1 bis 10 und schlafen dazwischen jeweils eine Sekunde lang. Schließlich führen wir ein Future aus, das mit einem asynchronen Block erstellt wurde, der an trpl::block_on übergeben wurde, so wie wir es im ganzen Kapitel getan haben. In diesem Future warten wir auf diese Nachrichten, genau wie in den anderen Beispielen mit Nachrichtenaustausch, die wir gesehen haben.

Um zu den Szenarien zurückzukehren, mit denen wir das Kapitel eröffnet haben, könnte man sich vorstellen, dass eine Reihe von Videokodierungsaufgaben über einen dedizierten Thread ausgeführt wird, da die Videokodierung rechenintensiv ist. Die Benutzeroberfläche kann aber über einen asynchronen Kanal informiert werden, wenn diese Vorgänge ausgeführt werden. Es gibt unzählige Beispiele für diese Art von Kombinationen in der Praxis.

Zusammenfassung

Dies ist nicht das letzte Mal, dass du in diesem Buch etwas über Nebenläufigkeit lesen wirst. Das Projekt in Kapitel 21 wird die Konzepte dieses Kapitels in einer realistischeren Situation anwenden als die hier besprochenen einfacheren Beispiele und einen direkteren Vergleich anstellen, wie es aussieht, wenn man diese Art von Problemen mit Threads und mit Aufgaben und Futures löst.

Welchen Ansatz du auch immer wählst, Rust gibt dir die Werkzeuge an die Hand, die du benötigst, um sicheren, schnellen und nebenläufigen Code zu schreiben – sei es für einen durchsatzstarken Webserver oder ein eingebettetes Betriebssystem.

Als nächstes werden wir über idiomatische Wege sprechen, Probleme zu modellieren und Lösungen zu strukturieren, wenn deine Rust-Programme größer werden. Außerdem werden wir erörtern, wie die Idiome von Rust mit denen verwandt sind, die du vielleicht aus der objektorientierten Programmierung kennst.