Furchtlose Parallelität in Rust: Threads zähmen, ohne den Schlaf zu verlieren
James Reed
Infrastructure Engineer · Leapcell

Konkurrierende Programme sind Programme, die mehrere Aufgaben ausführen (oder dies scheinbar tun), was bedeutet, dass zwei oder mehr Aufgaben die Ausführung innerhalb überlappender Zeiträume abwechseln. Diese Aufgaben werden von Threads ausgeführt – der kleinsten Verarbeitungseinheit. Hinter den Kulissen handelt es sich hierbei nicht um echtes Multitasking (parallele Verarbeitung), sondern um einen schnellen Kontextwechsel zwischen Threads mit Geschwindigkeiten, die für Menschen nicht wahrnehmbar sind. Viele moderne Anwendungen verlassen sich auf diese Illusion; beispielsweise kann ein Server eine Anfrage bearbeiten, während er auf andere wartet. Wenn Threads Daten gemeinsam nutzen, können viele Probleme auftreten, die häufigsten sind: Race Conditions und Deadlocks.
Rusts Eigentums-System und Typsicherheitssystem sind leistungsstarke Werkzeuge zur Lösung von Speichersicherheits- und Parallelitätsproblemen. Durch Eigentumsrechte und Typüberprüfung werden die meisten Fehler zur Kompilierzeit anstelle der Laufzeit abgefangen. Dadurch können Entwickler Code während der Entwicklung und nicht erst nach der Bereitstellung in der Produktion korrigieren. Sobald der Code kompiliert ist, können Sie darauf vertrauen, dass er in einer Multithread-Umgebung sicher ausgeführt wird, ohne die schwer zu verfolgenden Fehler, die in anderen Sprachen üblich sind. Dies bezeichnet Rust als furchtlose Parallelität.
Multithreading-Modell
Die Risiken der Multithread-Programmierung
In den meisten modernen Betriebssystemen wird die Ausführung von Programmcode innerhalb eines Prozesses ausgeführt, der vom Betriebssystem verwaltet wird. Innerhalb eines Programms kann es auch mehrere unabhängig auszuführende Komponenten geben, die als Threads bezeichnet werden.
Die Aufteilung der Programmberechnung in mehrere Threads kann die Leistung verbessern, da das Programm mehrere Aufgaben gleichzeitig bearbeiten kann. Dies erhöht jedoch auch die Komplexität. Da Threads gleichzeitig ausgeführt werden, gibt es keine Garantie für die Reihenfolge, in der Code in verschiedenen Threads ausgeführt wird. Dies kann zu Problemen führen wie:
- Race Conditions, bei denen mehrere Threads in inkonsistenter Reihenfolge auf Daten oder Ressourcen zugreifen
- Deadlocks, bei denen zwei Threads aufeinander warten, um Ressourcen freizugeben, die sie jeweils halten, was ein weiteres Fortschreiten verhindert
- Fehler, die nur unter bestimmten Umständen auftreten und schwer zu reproduzieren oder konsistent zu beheben sind
Programmiersprachen haben unterschiedliche Möglichkeiten, Threads zu implementieren. Viele Betriebssysteme bieten APIs zum Erstellen neuer Threads. Wenn eine Sprache die OS-API zum Erstellen von Threads verwendet, wird dies oft als 1:1-Modell bezeichnet, bei dem ein OS-Thread einem Thread auf Sprachebene entspricht.
Rusts Standardbibliothek bietet nur das 1:1-Threading-Modell.
Erstellen neuer Threads mit spawn
use std::thread; use std::time::Duration; fn main() { let thread = thread::spawn(|| { for i in 1..10 { println!("this is thread {}", i); thread::sleep(Duration::from_millis(1)); } }); for k in 1..5 { println!("this is main {}", k); thread::sleep(Duration::from_millis(1)); } }
Ausgabe:
this is main 1
this is thread 1
this is main 2
this is thread 2
this is main 3
this is thread 3
this is main 4
this is thread 4
this is thread 5
Wir sehen, dass nach Abschluss der 5-Schleifen-Iteration des Haupt-Threads und dem Beenden der neu erstellte Thread – obwohl für 10 Iterationen ausgelegt – nur 5 Iterationen ausführt und dann beendet wird. Wenn der Haupt-Thread endet, endet auch der neue Thread, unabhängig davon, ob er abgeschlossen ist.
Wenn wir möchten, dass der neue Thread beendet wird, bevor der Haupt-Thread fortfährt, können wir JoinHandle
verwenden:
use std::thread; use std::time::Duration; fn main() { let handler = thread::spawn(|| { for i in 1..10 { println!("this is thread {}", i); thread::sleep(Duration::from_millis(1)); } }); for k in 1..5 { println!("this is main {}", k); thread::sleep(Duration::from_millis(1)); } handler.join().unwrap(); // Block the main thread until the new thread finishes }
Ausgabe:
this is main 1
this is thread 1
this is main 2
this is thread 2
this is main 3
this is thread 3
this is main 4
this is thread 4
this is thread 5
this is thread 6
this is thread 7
this is thread 8
this is thread 9
Der Rückgabetyp von thread::spawn
ist JoinHandle
. JoinHandle
ist ein eigener Wert, und das Aufrufen seiner join
-Methode wartet, bis der Thread beendet ist.
Das Aufrufen von join
auf einem Handle blockiert den aktuellen Thread, bis der durch das Handle dargestellte Thread endet. Blockieren eines Threads bedeutet, ihn daran zu hindern, weitere Arbeiten auszuführen oder zu beenden.
Threads und move
Closures
Wir können eine move
Closure verwenden, um das Eigentum an Variablen vom Haupt-Thread in die Closure zu übertragen:
use std::thread; fn main() { let v = vec![2, 4, 5]; // `move` transfers ownership of `v` into the closure let thread = thread::spawn(move || { println!("v is {:?}", v); }); }
Ausgabe:
v is [2, 4, 5]
Rust verschiebt das Eigentum an der Variablen v
in den neuen Thread. Dies stellt sicher, dass die Variable im neuen Thread sicher verwendet werden kann, und bedeutet auch, dass der Haupt-Thread v
nicht mehr verwenden kann (z. B. um sie zu löschen).
Wenn das Schlüsselwort move
weggelassen wird, gibt der Compiler einen Fehler aus:
$ cargo run 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!("Here's a vector: {:?}", v); | - `v` is borrowed here
Rusts Eigentumsregeln haben wieder einmal dazu beigetragen, die Speichersicherheit zu gewährleisten!
Nachrichtenübermittlung
In Rust ist ein primäres Werkzeug für Message-Passing-Concurrency der Channel, ein Konzept, das von der Standardbibliothek bereitgestellt wird. Sie können sich das wie einen Wasserkanal vorstellen – einen Fluss oder Bach. Wenn Sie etwas wie eine Gummiente oder ein Boot hineinlegen, fließt es flussabwärts zum Empfänger.
Ein Channel besteht aus zwei Teilen: einem Sender und einem Empfänger. Wenn entweder der Sender oder der Empfänger gelöscht wird, gilt der Channel als geschlossen.
Kanäle werden über std::sync::mpsc
der Standardbibliothek implementiert, was für Multiple Producer, Single Consumer steht.
Hinweis: Basierend auf der Anzahl der Leser und Schreiber können Kanäle wie folgt kategorisiert werden:
- SPSC – Single Producer, Single Consumer (kann nur Atomics verwenden)
- SPMC – Single Producer, Multiple Consumers (erfordert Sperren auf der Konsumentenseite)
- MPSC – Multiple Producers, Single Consumer (erfordert Sperren auf der Produzentenseite)
- MPMC – Multiple Producers, Multiple Consumers
Übergeben von Nachrichten zwischen Threads
use std::thread; use std::sync::mpsc; fn main() { let (tx, rx) = mpsc::channel(); // Move `tx` into the closure so the new thread owns it thread::spawn(move || { tx.send("hello").unwrap(); }); // `recv()` blocks the main thread until a value is received let msg = rx.recv().unwrap(); println!("message is {}", msg); }
Ausgabe:
message is hello
Das empfangende Ende des Kanals hat zwei nützliche Methoden: recv
und try_recv
.
Hier haben wir recv
verwendet, kurz für receive, das den Haupt-Thread blockiert, bis ein Wert empfangen wird. Sobald ein Wert gesendet wurde, gibt recv
ihn in einem Result<T, E>
zurück. Wenn der Sender geschlossen ist, wird ein Fehler zurückgegeben, der angibt, dass keine weiteren Werte mehr eintreffen werden.
try_recv
blockiert nicht, sondern gibt stattdessen sofort mit einem Result<T, E>
zurück: Ok
, wenn Daten verfügbar sind, oder Err
, wenn nicht.
Wenn der neue Thread die Ausführung noch nicht abgeschlossen hat, kann die Verwendung von try_recv
zu einem Laufzeitfehler führen:
use std::thread; use std::sync::mpsc; fn main() { let (tx, rx) = mpsc::channel(); thread::spawn(move || { tx.send("hello").unwrap(); }); // `try_recv` returns immediately, so it might not receive the message in time let msg = rx.try_recv().unwrap(); println!("message is {}", msg); }
Fehler:
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Empty', ...
Senden mehrerer Werte und Beobachten des wartenden Empfängers
use std::thread; use std::sync::mpsc; use std::time::Duration; fn main() { let (tx, rx) = mpsc::channel(); thread::spawn(move || { let vals = vec![ String::from("hi"), String::from("from"), String::from("the"), String::from("thread"), ]; for val in vals { tx.send(val).unwrap(); thread::sleep(Duration::from_secs(1)); } }); // The `for` loop on `rx` implicitly waits for incoming values as an iterator for received in rx { println!("Got: {}", received); } }
Beispielausgabe (mit 1-sekündigen Pausen zwischen den Zeilen):
Got: hi
Got: from
Got: the
Got: thread
Erstellen mehrerer Producer durch Klonen des Senders
use std::thread; use std::sync::mpsc; use std::time::Duration; fn main() { let (tx, rx) = mpsc::channel(); // Clone the sender `tx` to create a second producer let tx1 = tx.clone(); thread::spawn(move || { let vals = vec![ String::from("hi"), String::from("from"), String::from("the"), String::from("thread"), ]; for val in vals { tx1.send(val).unwrap(); // Use the cloned sender thread::sleep(Duration::from_secs(1)); } }); thread::spawn(move || { let vals = vec![ String::from("more"), String::from("messages"), String::from("for"), String::from("you"), ]; for val in vals { tx.send(val).unwrap(); // Use the original sender thread::sleep(Duration::from_secs(1)); } }); // Both tx and tx1 send values to the same receiver rx for received in rx { println!("Got: {}", received); } }
Beispielausgabe (variiert je nach System aufgrund der Zeitplanung):
Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you
Gemeinsamer Zustand
Gemeinsamer Zustand oder gemeinsame Daten bedeuten, dass mehrere Threads gleichzeitig auf denselben Speicherort zugreifen. Rust verwendet Mutexes (Mutual Exclusion Locks), um Shared-Memory-Concurrency-Primitive zu implementieren.
Ein Mutex ermöglicht nur einem Thread den gleichzeitigen Zugriff auf Daten
Mutex ist die Abkürzung für Mutual Exclusion, was bedeutet, dass nur ein Thread zu einem bestimmten Zeitpunkt auf bestimmte Daten zugreifen kann. Um auf Daten in einem Mutex zuzugreifen, muss ein Thread zuerst die Sperre erwerben. Die Sperre ist eine Datenstruktur, die verfolgt, wer derzeit exklusiven Zugriff hat.
Verwenden der std::sync::Mutex
der Standardbibliothek:
use std::sync::Mutex; fn main() { let m = Mutex::new(5); { let mut num = m.lock().unwrap(); *num = 10; // Mutate the value inside the Mutex println!("num is {}", num); } println!("m is {:?}", m); }
Ausgabe:
num is 10
m is Mutex { data: 10 }
Wir verwenden die lock
-Methode, um Zugriff auf die Daten im Mutex zu erhalten. Dieser Aufruf blockiert den aktuellen Thread, bis die Sperre erworben wurde.
Ein Mutex
ist ein Smart Pointer. Genauer gesagt, lock
gibt ein MutexGuard
zurück, das ein Smart Pointer ist, der Deref
implementiert, um auf die zugrunde liegenden Daten zu verweisen. Es implementiert auch Drop
, sodass die Sperre automatisch freigegeben wird, wenn MutexGuard
den Gültigkeitsbereich verlässt.
Gemeinsame Nutzung eines Mutex zwischen Threads
Beim Austauschen von Daten zwischen mehreren Threads, bei denen mehrere Eigentümer erforderlich sind, verwenden wir den Arc
Smart Pointer, um den Mutex
zu umschließen. Arc
ist Thread-sicher; Rc
ist es nicht und kann in Multithread-Kontexten nicht sicher verwendet werden.
Hier ist ein Beispiel für die Verwendung von Arc
zum Umschließen eines Mutex
, das die gemeinsame Nutzung über Threads hinweg ermöglicht:
use std::sync::{Mutex, Arc}; 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); // Clone the Arc before moving into the thread 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()); }
Ausgabe:
Result: 10
Zusammenfassend:
Rc<T>
+RefCell<T>
wird typischerweise für Single-Threaded Interior Mutability verwendetArc<T>
+Mutex<T>
wird für Multithreaded Interior Mutability verwendet
Hinweis: Mutex
kann immer noch Deadlocks verursachen. Dies kann passieren, wenn zwei Operationen jeweils zwei Ressourcen sperren müssen und zwei Threads jeweils eine Sperre halten und auf die andere warten – was zu einer zirkulären Wartebedingung führt.
Thread-Sicherheit basierend auf Send
und Sync
In Rust sind Parallelitäts-bezogene Tools Teil der Standardbibliothek, nicht der Sprache selbst. Es gibt jedoch zwei Parallelitätskonzepte, die in die Sprache eingebettet sind: die Send
- und Sync
-Traits in std::marker
.
Zweck von Send
und Sync
Send
und Sync
sind das Herzstück der sicheren Parallelität in Rust. Technisch gesehen sind dies Marker-Traits (Traits, die keine Methoden definieren) und werden verwendet, um Typen für das Parallelitätsverhalten zu kennzeichnen:
- Ein Typ, der
Send
implementiert, kann sein Eigentum sicher zwischen Threads übertragen. - Ein Typ, der
Sync
implementiert, kann über Referenzen zwischen Threads ausgetauscht werden.
Daraus können wir schließen: Wenn &T
Send
ist, dann ist T
Sync
.
Typen, die Send
und Sync
implementieren
In Rust implementieren fast alle Typen standardmäßig Send
und Sync
. Dies bedeutet, dass für zusammengesetzte Typen (wie Strukturen) wenn alle ihre Mitglieder Send
oder Sync
sind, der zusammengesetzte Typ diese Traits automatisch erbt.
Aber wenn auch nur ein Mitglied nicht Send
oder Sync
ist, dann ist der gesamte Typ es nicht.
Zusammenfassend:
- Typen, die
Send
implementieren, können Eigentum sicher zwischen Threads übertragen. - Typen, die
Sync
implementieren, können sicher zwischen Threads ausgetauscht werden (per Referenz). - Die überwiegende Mehrheit der Typen in Rust sind sowohl
Send
als auchSync
.
Zu den gängigen Typen, die diese Traits nicht implementieren, gehören:
- Raw Pointers
- Cell, RefCell
- Rc
Es ist möglich, Send
und Sync
manuell für Ihre eigenen Typen zu implementieren, aber:
- Sie müssen
unsafe
Code verwenden - Sie müssen manuell die Thread-Sicherheit gewährleisten
- Dies ist selten notwendig und sollte mit großer Vorsicht erfolgen
Hinweis:
Cell
undRefCell
sind nichtSync
, da ihre Kernimplementierung (UnsafeCell
) nichtSync
ist.Rc
ist wederSend
nochSync
, da sein interner Referenzzähler nicht Thread-sicher ist.- Raw Pointers implementieren keinen der Traits, da sie keine Sicherheitsgarantien bieten.
Zusammenfassung
Rust bietet sowohl async/await- als auch Multithreaded-Concurrency-Modelle. Um das Multithreaded-Modell effektiv zu nutzen, muss man Rusts Threading-Grundlagen verstehen, einschließlich:
- Thread-Erstellung
- Thread-Synchronisation
- Thread-Sicherheit
Rust unterstützt:
- Message-Passing-Concurrency, wobei
channel
s verwendet werden, um Daten zwischen Threads zu übertragen - Shared-State-Concurrency, wobei
Mutex
undArc
verwendet werden, um Daten über Threads hinweg gemeinsam zu nutzen und sicher zu mutieren
Das Typsystem und der Borrow Checker stellen sicher, dass diese Muster frei von Data Races und Dangling References sind.
Sobald der Code kompiliert ist, können Sie sicher sein, dass er in Multithreaded-Umgebungen korrekt ausgeführt wird, ohne die schwer fassbaren, schwer zu debuggenden Fehler, die in anderen Sprachen zu sehen sind.
Die Send
- und Sync
-Traits bieten die Garantien für die sichere Übertragung oder den sicheren Austausch von Daten zwischen Threads.
Zusammenfassend:
- Threading-Modell: Multithreaded-Programme müssen Race Conditions, Deadlocks und schwer zu reproduzierende Fehler behandeln.
- Message Passing: Verwendet Kanäle, um Daten zwischen Threads zu übertragen.
- Shared State:
Mutex
+Arc
ermöglichen es mehreren Threads, auf dieselben Daten zuzugreifen und diese zu mutieren. - Thread-Sicherheit:
Send
- undSync
-Traits garantieren die Sicherheit der Datenübertragung und -freigabe in Multithreaded-Kontexten.
Wir sind Leapcell, Ihre erste Wahl für das Hosten von Rust-Projekten.
Leapcell ist die Next-Gen-Serverless-Plattform für Webhosting, asynchrone Aufgaben und Redis:
Multi-Language-Support
- Entwickeln Sie mit Node.js, Python, Go oder Rust.
Stellen Sie unbegrenzt Projekte kostenlos bereit
- Zahlen Sie nur für die Nutzung – keine Anfragen, keine Gebühren.
Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 US-Dollar unterstützen 6,94 Millionen Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollautomatische CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und -protokollierung für umsetzbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Automatische Skalierung zur einfachen Bewältigung hoher Parallelität.
- Null Betriebsaufwand – konzentrieren Sie sich einfach auf das Bauen.
Erfahren Sie mehr in der Dokumentation!
Folgen Sie uns auf X: @LeapcellHQ