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
Sendimplementiert, kann sein Eigentum sicher zwischen Threads übertragen. - Ein Typ, der
Syncimplementiert, 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
Sendimplementieren, können Eigentum sicher zwischen Threads übertragen. - Typen, die
Syncimplementieren, können sicher zwischen Threads ausgetauscht werden (per Referenz). - Die überwiegende Mehrheit der Typen in Rust sind sowohl
Sendals 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
unsafeCode verwenden - Sie müssen manuell die Thread-Sicherheit gewährleisten
- Dies ist selten notwendig und sollte mit großer Vorsicht erfolgen
Hinweis:
CellundRefCellsind nichtSync, da ihre Kernimplementierung (UnsafeCell) nichtSyncist.Rcist wederSendnochSync, 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
channels verwendet werden, um Daten zwischen Threads zu übertragen - Shared-State-Concurrency, wobei
MutexundArcverwendet 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+Arcermö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



