Anleitung für Anfänger zur nebenläufigen Programmierung in Rust
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Nebenläufigkeit und Parallelität
Viele Leute können die Konzepte der Nebenläufigkeit und Parallelität nicht unterscheiden. Bevor wir also in die asynchrone Programmierung in Rust eintauchen, ist es wichtig, zuerst den Unterschied zwischen Nebenläufigkeit und Parallelität zu verstehen.
Wir stoßen in Lehrbüchern über Betriebssysteme oft auf die folgenden Definitionen:
-
Nebenläufigkeit bezieht sich auf zwei oder mehr Ereignisse, die innerhalb desselben Zeitintervalls stattfinden.
-
Parallelität bezieht sich auf die Fähigkeit eines Systems, Berechnungen oder Operationen gleichzeitig durchzuführen.
-
Erklärung 1: Nebenläufigkeit bedeutet, dass zwei oder mehr Ereignisse innerhalb desselben Zeitintervalls auftreten, während Parallelität bedeutet, dass zwei oder mehr Ereignisse im selben Augenblick auftreten.
-
Erklärung 2: Nebenläufigkeit bezieht sich auf mehrere Ereignisse auf derselben Entität, während Parallelität sich auf mehrere Ereignisse auf verschiedenen Entitäten bezieht.
-
Erklärung 3: Nebenläufigkeit ist die "gleichzeitige" Bearbeitung mehrerer Aufgaben auf einem einzelnen Prozessor, während Parallelität die gleichzeitige Bearbeitung von Aufgaben auf mehreren Prozessoren ist, wie z. B. in einem verteilten Cluster.
Rob Pike, einer der Schöpfer von Golang, gab eine sehr aufschlussreiche und intuitive Erklärung:
Bei Nebenläufigkeit geht es darum, viele Dinge auf einmal zu erledigen. Bei Parallelität geht es darum, viele Dinge auf einmal zu tun.
Bei Nebenläufigkeit geht es um die Fähigkeit, viele Aufgaben auf einmal zu bearbeiten, während Parallelität die Technik ist, viele Aufgaben auf einmal auszuführen.
Wenn wir Aufgaben zur Verarbeitung in mehrere Threads oder asynchrone Aufgaben einteilen, nutzen wir die Nebenläufigkeit. Wenn diese Threads oder Async-Aufgaben gleichzeitig auf einer Multi-Core- oder Multi-CPU-Maschine laufen, nutzen wir die Parallelität. In gewisser Weise ermöglicht Nebenläufigkeit die Parallelität. Sobald wir die Fähigkeit haben, nebenläufige Aufgaben zu bearbeiten, ergibt sich die Parallelität ganz natürlich.
- Nebenläufigkeit: Bezieht sich darauf, dass zu jedem gegebenen Zeitpunkt nur eine Anweisung ausgeführt wird, aber mit mehreren Prozessanweisungen, die schnell ein- und ausgeblendet werden, was die Makro-Illusion einer gleichzeitigen Ausführung erzeugt. Auf der Mikroebene sind sie jedoch nicht wirklich gleichzeitig; die Zeit wird in Segmente unterteilt, um eine schnelle, abwechselnde Ausführung zwischen Prozessen zu ermöglichen.
- Parallelität: Bezieht sich auf die gleichzeitige Ausführung mehrerer Anweisungen auf verschiedenen Prozessoren. Sowohl auf der Mikro- als auch auf der Makroebene werden Aufgaben gleichzeitig ausgeführt und verarbeitet.
Wenn mehrere Threads in Betrieb sind und das System nur eine CPU hat, ist es unmöglich, dass mehr als ein Thread wirklich gleichzeitig ausgeführt wird. Das System teilt die CPU-Zeit in Segmente auf und weist sie den Threads zu. Wenn ein Thread läuft, werden die anderen angehalten. Dieser Ansatz wird Nebenläufigkeit genannt.
Wenn das System mehr als eine CPU hat, sind Thread-Operationen möglicherweise nicht nebenläufig. Während eine CPU einen Thread ausführt, kann eine andere CPU einen anderen Thread ausführen. Die Threads konkurrieren nicht um dieselben CPU-Ressourcen und können gleichzeitig ausgeführt werden. Dies wird als Parallelität bezeichnet.
Hier ist eine Schlussfolgerung: Sowohl Nebenläufigkeit als auch Parallelität beschreiben Multi-Tasking. Bei der Nebenläufigkeit geht es um abwechselnde Ausführung und den Fokus auf Bearbeitungskapazität, wie z. B. Nebenläufigkeitsebenen; bei der Parallelität geht es um gleichzeitige Ausführung und den Fokus auf die Ausführungsstrategie, wie z. B. Aufgabenparallelität.
Nebenläufige Programmiermodelle
Wir wissen, dass verschiedene Programmiersprachen unterschiedliche Implementierungen haben, was zu unterschiedlichen Nebenläufigkeitsmodellen zwischen den Sprachen führt. Wenn wir ein Programm in einer bestimmten Sprache schreiben und kompilieren, belegt dieses Programm einen Prozess, wenn es läuft. Innerhalb dieses Prozesses können Threads erstellt werden, und diese befinden sich auf der Ebene des Betriebssystems. Innerhalb der Sprache selbst gelten vom Programmierer mithilfe von Sprachfunktionen erstellte Threads als Threads auf Sprachebene. Ob diese beiden Arten von Threads eins zu eins sind, hängt von der internen Implementierung der Sprache ab:
- OS-native Threads: Beispielsweise ruft die Rust-Sprache direkt von dem Betriebssystem bereitgestellte APIs auf, sodass die Anzahl der Threads im Programm mit der Anzahl der verwendeten OS-Threads übereinstimmt.
- Coroutinen: Ähnlich wie die Go-Sprache funktioniert: Intern werden M-Threads im Programm auf irgendeine Weise N-Betriebssystem-Threads zugeordnet.
- Event-driven: Dieses Modell wird oft in Kombination mit Rückrufen verwendet. Es ist sehr leistungsstark, aber sein größtes Problem ist das Risiko der Callback-Hölle.
- Actor-Modell: Basierend auf Message Passing führt dieses Modell nebenläufige Berechnungen auf zerlegten kleinen Einheiten durch. Es ist ein Killer-Feature der Erlang-Sprache.
- Async/await-Modell: Dieses Modell hat eine hohe Leistung, unterstützt Low-Level-Programmierung und verhält sich ähnlich wie Threads oder Coroutinen, ohne dass wesentliche Änderungen am Programmiermodell erforderlich sind. Der Kompromiss ist jedoch, dass sein interner Implementierungsmechanismus recht komplex ist.
Kurz gesagt, nach Abwägung der Kompromisse hat sich Rust letztendlich dafür entschieden, sowohl Multi-Threading als auch Async/Await als zwei Modelle für die nebenläufige Programmierung anzubieten:
- Multi-Threading wird in der Standardbibliothek durch direkten Aufruf von OS-APIs implementiert. Es ist einfach zu implementieren und zu verwenden, wodurch es sich für Szenarien mit einer kleinen Anzahl von nebenläufigen Aufgaben eignet.
- Async/Await ist komplexer zu implementieren, aber Rust verwendet eine Kombination aus Sprachmerkmalen, Standardbibliotheken und Bibliotheken von Drittanbietern, um es zu abstrahieren und zu kapseln. Dies ermöglicht es Entwicklern, Async/Await zu verwenden, ohne sich um die zugrunde liegende Implementierungslogik kümmern zu müssen. Es eignet sich für groß angelegte Nebenläufigkeit und asynchrone I/O.
Asynchrone Programmierung in Rust
Asynchrone Programmierung ist ein Modell für die nebenläufige Programmierung. Es ermöglicht uns, eine große Anzahl von Aufgaben gleichzeitig auszuführen, während nur wenige – oder sogar ein einziger – OS-Thread oder CPU-Kern benötigt werden. In Bezug auf die Nutzungserfahrung ist die moderne asynchrone Programmierung kaum von der synchronen Programmierung zu unterscheiden.
Viele Sprachen unterstützen heute die asynchrone Programmierung über async
, aber RUSTs Implementierung unterscheidet sich in einigen wichtigen Punkten:
- Futures sind in Rust lazy: Sie beginnen erst zu laufen, wenn sie gepollt werden. Das Verwerfen einer Future verhindert, dass sie jemals ausgeführt wird. Sie können sich ein
Future
als eine Aufgabe vorstellen, die zu einem bestimmten Zeitpunkt in der Zukunft ausgeführt werden soll. - Zero-Cost Abstraction: Die Verwendung von
async
in Rust verursacht keine Laufzeitkosten. Das bedeutet, dass nur der von Ihnen geschriebene Code (Ihr sichtbarer Code) einen Performance-Overhead verursacht; die interne Implementierung vonasync
führt keinerlei Performance-Einbußen mit sich. Sie müssen beispielsweise weder Heap-Speicher zuweisen noch dynamisches Dispatching durchführen, umasync
zu verwenden. Dies kommt der Performance sehr zugute, insbesondere bei Hot Paths, und ist einer der Gründe, warum die Async-Performance von Rust so hoch ist. - Rust enthält keine integrierte Laufzeit, die für asynchrone Aufrufe erforderlich ist, aber das ist kein Problem – RUSTs Ökosystem bietet hervorragende Laufzeitimplementierungen, wie z. B. das bekannte Tokio.
- Die Laufzeit unterstützt sowohl Single-Threaded- als auch Multi-Threaded-Modi, jeder mit seinen eigenen Vorteilen und Kompromissen, die später erläutert werden.
Die Wahl zwischen Async und Multithreading
Obwohl sowohl async
als auch Multithreading verwendet werden können, um eine nebenläufige Programmierung zu erreichen – und letzteres die Nebenläufigkeit sogar durch Thread-Pools verbessern kann – sind diese beiden Ansätze nicht austauschbar. Der Wechsel von einem zum anderen erfordert oft eine umfangreiche Code-Refactoring. Daher ist es äußerst wichtig, die Unterschiede und Anwendungsbereiche zu verstehen und die richtige Wahl im Voraus zu treffen.
- Für CPU-intensive Aufgaben, wie z. B. parallele Berechnungen, ist Multithreading vorteilhafter. Das liegt daran, dass solche Aufgaben dazu neigen, die Threads lange Zeit voll ausgelastet zu halten. Die Anzahl der Threads, die Sie erstellen, sollte der Anzahl der CPU-Kerne entsprechen, um die parallelen Verarbeitungsfunktionen voll auszuschöpfen. In diesem Fall ist es nicht notwendig, Threads häufig zu erstellen oder zu wechseln, da jeder Thread-Context-Switch Performance-Overhead verursacht. Sie können Threads an bestimmte CPU-Kerne binden, um diesen Overhead zu reduzieren.
- Bei IO-intensiven Aufgaben, wie z. B. Webserver, Datenbankverbindungen und andere Netzwerkdienste, ist asynchrone Programmierung vorteilhafter. Das liegt daran, dass diese Aufgaben die meiste Zeit mit Warten verbringen. Wenn Sie Multithreading verwenden, bleiben die meisten Threads die meiste Zeit im Leerlauf. In Kombination mit den hohen Kosten für den Thread-Context-Switch führt dies zu einem erheblichen Performance-Verlust. Mit
async
können Sie CPU- und Speichernutzung effektiv reduzieren und gleichzeitig eine große Anzahl von Aufgaben gleichzeitig ausführen. Sobald eine Aufgabe in einen IO- oder einen anderen blockierenden Zustand eintritt, gibt sie sofort nach, sodass eine andere Aufgabe ausgeführt werden kann. Die Kosten für das Umschalten von Aufgaben inasync
sind viel geringer als der Thread-Context-Switch im Multithreading.
Es ist wichtig zu beachten: Async basiert auch unter der Haube auf Threads. Es umschließt jedoch Threads über eine Laufzeit, die mehrere Aufgaben auf eine kleine Anzahl von Threads abbildet. Im Wesentlichen wirft es eine große Anzahl von IO-gebundenen, nebenläufigen Ereignissen in eine kleine Anzahl von Threads und kommuniziert effizient über Ereignisse.
Die Kosten für diesen Ansatz sind, dass er die Laufzeit von Rust-Programmen erhöht (wobei die Laufzeit der Rust-Code ist, der in jede ausführbare Datei gebündelt wird). Dies führt zu einer deutlichen Erhöhung der Größe der kompilierten Binärdatei.
Lassen Sie uns den Unterschied zwischen den beiden anhand eines einfachen Beispiels veranschaulichen: Angenommen, wir möchten zwei Dateien herunterladen. Wir könnten sie nacheinander herunterladen (serielle Ausführung), aber das ist offensichtlich nicht der schnellste Ansatz. Natürlich denken wir an die Verwendung von Multithreading für paralleles Herunterladen:
Multithreaded Programming:
fn download_two_files() { // Create two new threads to perform the tasks let thread_one = thread::spawn(|| download("URL1")); let thread_two = thread::spawn(|| download("URL2")); // Wait for both threads to complete thread_one.join().expect("thread one panic"); thread_two.join().expect("thread two panic"); }
Wenn Sie jedes Mal nur ein oder zwei Dateien herunterladen, funktioniert dieser Ansatz gut. Das Problem tritt jedoch auf, wenn Sie Hunderte oder Tausende von Dateien gleichzeitig herunterladen müssen – jede Download-Aufgabe verbraucht einen Thread, und die Ressourcenkosten für Threads steigen schnell an (Threads sind immer noch zu schwer). In diesem Fall könnten Sie die Verwendung von async
in Betracht ziehen:
Async Programming:
async fn get_two_sites_async() { // Create two separate futures // You can think of a future as a scheduled task to be executed at some future point—similar to a Promise in JS // Once both futures are run, they will download the target pages concurrently let future_one = download_async("URL1"); let future_two = download_async("URL2"); // Run both futures concurrently until they complete join!(future_one, future_two); }
Im Vergleich zum Multithreaded-Modell zeigt Async hier seinen Vorteil: Bei gleichem Nebenläufigkeitsgrad reduziert es die Kosten für das Erstellen und Umschalten von Threads.
Zusammenfassung
Nebenläufigkeit und Parallelität sind beides Beschreibungen der Multi-Task-Verarbeitung. Nebenläufigkeit bezieht sich auf Aufgaben, die abwechselnd bearbeitet werden, während sich Parallelität auf Aufgaben bezieht, die gleichzeitig bearbeitet werden. Nebenläufige Programmierung bedeutet, dass verschiedene Teile eines Programms unabhängig voneinander ausgeführt werden, während parallele Programmierung bedeutet, dass verschiedene Teile eines Programms gleichzeitig ausgeführt werden.
In Bezug auf nebenläufige Programmiermodelle hat Rust aufgrund seiner Sprachdesignphilosophie – die Sicherheit, Performance und Kontrolle betont – nicht den Ansatz der "radikalen Einfachheit" wie Go gewählt. Stattdessen hat es sich für eine Kombination aus Multi-Threading und Async/Await entschieden. Der Vorteil davon ist eine stärkere Kontrolle und eine höhere Performance. Der Nachteil ist eine höhere Komplexität. Aber das ist natürlich ein erwarteter Kompromiss für eine Systemprogrammiersprache: Komplexität im Austausch für Kontrolle und Performance.
In der Tat schließen sich Async und Multithreading nicht gegenseitig aus – in vielen Anwendungen werden beide zusammen verwendet. Obwohl sowohl async
als auch Multithreading eine nebenläufige Programmierung ermöglichen – und Multithreading sogar Thread-Pools nutzen kann, um die Nebenläufigkeit zu erhöhen – sind diese beiden Modelle nicht interoperabel. Der Wechsel von einem zum anderen erfordert eine umfangreiche Code-Refactoring. Daher ist die Wahl des richtigen Nebenläufigkeitsmodells von Anfang an entscheidend für Ihr Projekt.
Zusammenfassend lässt sich sagen, dass asynchrone Programmierung für IO-gebundene Aufgaben geeignet ist, während Multithreading für CPU-gebundene Aufgaben geeignet ist. Hier ist eine kurze Zusammenfassung der Auswahlregeln:
- Wenn Sie eine große Anzahl von IO-Aufgaben haben, die gleichzeitig ausgeführt werden müssen, wählen Sie das Async-Modell.
- Wenn Sie einige wenige IO-Aufgaben haben, die gleichzeitig ausgeführt werden müssen, wählen Sie Multithreading. Wenn Sie den Overhead durch das Erstellen und Zerstören von Threads reduzieren möchten, können Sie einen Thread-Pool verwenden.
- Wenn Sie eine große Anzahl von CPU-intensiven Aufgaben parallel ausführen müssen (z. B. rechenintensive Berechnungen), wählen Sie das Multithreading-Modell und versuchen Sie, die Anzahl der Threads mit der Anzahl der CPU-Kerne übereinstimmen oder leicht zu überschreiten.
- Wenn die Wahl eigentlich keine Rolle spielt, verwenden Sie standardmäßig Multithreading.
Wir sind Leapcell, Ihre erste Wahl für das Hosting von Rust-Projekten.
Leapcell ist die Serverless-Plattform der nächsten Generation für Webhosting, asynchrone Aufgaben und Redis:
Multi-Language Support
- Entwickeln Sie mit Node.js, Python, Go oder Rust.
Unbegrenzte Projekte kostenlos bereitstellen
- Sie zahlen nur für die Nutzung – keine Anfragen, keine Gebühren.
Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 $ unterstützen 6,94 Mio. Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Developer Experience
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollständig automatisierte CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und -protokollierung für verwertbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Auto-Skalierung zur einfachen Bewältigung hoher Nebenläufigkeit.
- Keine betrieblichen Gemeinkosten – konzentrieren Sie sich einfach auf den Bau.
Weitere Informationen finden Sie in der Dokumentation!
Folgen Sie uns auf X: @LeapcellHQ