Send und Sync in Rust Async Handlern verstehen
Daniel Hayes
Full-Stack Engineer · Leapcell

Einführung
Sie kennen das wahrscheinlich: Sie schreiben einen Async Handler in Rust, fühlen sich produktiv, stoßen aber auf einen Compilerfehler, der sich über Send oder Sync beschwert. Dies ist ein häufiges Hindernis für Entwickler, die neu im Nebenläufigkeitsmodell von Rust sind, insbesondere wenn sie sich in die Welt von async/await wagen. Dies ist keine kryptische Fehlermeldung; es ist das rigorose Typsystem von Rust, das die Sicherheit und Korrektheit Ihres nebenläufigen Codes gewährleistet. Das Ignorieren oder Missverstehen dieser Traits kann zu subtilen, schwer zu debuggenden Datenrennen oder Deadlocks führen. In diesem Artikel werden wir die Schichten von Send und Sync aufdecken, erklären, warum sie für Async Handler entscheidend sind, und praktische Lösungen bereitstellen, um robusten, threadsicheren asynchronen Rust-Code zu schreiben.
Die Grundlage der Rust-Nebenläufigkeit
Bevor wir uns mit den Besonderheiten von Async Handlern befassen, wollen wir ein klares Verständnis der Kernkonzepte entwickeln, die die Threadsicherheit in Rust untermauern: Send und Sync.
Was sind Send und Sync?
In Rust sind Send und Sync Marker Traits. Sie haben keine Methoden; ihre Absicht ist rein semantisch und informiert den Compiler über die Threadsicherheitseigenschaften eines Typs.
-
Send: Ein TypTistSend, wenn es sicher ist, den Besitz eines Wertes vom TypTvon einem Thread auf einen anderen zu übertragen. Die meisten primitiven Typen (wiei32,bool), Smart Pointer wieBox<T>(wennTSendist) und viele Sammlungstypen (Vec<T>,HashMap<K, V>, wenn ihre InhalteSendsind) sindSend. Umgekehrt sind Raw Pointer (*const T,*mut T) nichtSend, da ihre Übertragung ohne ordnungsgemäße Synchronisierung zu Datenrennen führen kann, wenn sie auf verschiedenen Threads dereferenziert werden. Kanäle und Mutexes sind typischerweiseSend, da sie interne Zustände verwalten, um eine sichere Cross-Thread-Kommunikation zu ermöglichen. -
Sync: Ein TypTistSync, wenn es sicher ist, dass mehrere Threads gemeinsame Referenzen (&T) auf einen Wert vom TypThalten können. Das bedeutet, dassTsicher gleichzeitig zugegriffen werden kann. DamitTSyncist, muss&TSendsein, was impliziert, dass eine gemeinsame Referenz aufTan einen anderen Thread gesendet und dort zugegriffen werden kann. Unveränderliche Daten wiei32oder&strsindSync. Typen, die interne Veränderlichkeit durch Mechanismen wieMutex<T>oderRwLock<T>(wennTSendist) bereitstellen, sind ebenfallsSync, da ihre internen Mechanismen einen sicheren gleichzeitigen Zugriff gewährleisten.RefCell<T>und Raw Pointer (*const T,*mut T) sind nichtSync, da sie keine threadsichere interne Veränderlichkeit bieten.
Die meisten Typen in Rust sind standardmäßig Send und Sync (impl Trait for T {}). Typen werden nur dann nicht-Send oder nicht-Sync, wenn sie Felder enthalten, die nicht Send oder nicht Sync sind, oder wenn sie sich explizit durch ![derive(Send)] oder ![derive(Sync)] dagegen entscheiden (obwohl dies selten vorkommt).
Warum sind Send und Sync für Async wichtig?
Asynchrones Rust, angetrieben von Futures und Executoren, stützt sich stark auf diese Traits, auch wenn es auf den ersten Blick wie eine Single-Threaded-Ausführung erscheint. Eine async fn oder ein async {} Block wird zu einer Zustandsmaschine, die das Future Trait implementiert. Dieses Future muss sicher von einem Executor abgefragt werden.
Betrachten Sie einen Executor (wie Tokio oder async-std), der einen Thread-Pool verwaltet. Wenn Sie ein Future spawnen, auf ein anderes Future .awaiten oder eine Closure in einen Async-Block verschieben, kann der Executor den Zustand des Future zwischen den Threads verschieben, um die Arbeit auszugleichen, oder ihn nachfolgenden .await-Aufrufen von einem anderen Thread abfragen.
FuturemussSendsein: Wenn einFutureeinen Zustand enthält, der nichtSendist, kann der Executor den Zustand desFuturenicht sicher zwischen den Threads verschieben. Dies ist entscheidend für Multi-Threaded-Executoren, die Aufgaben neu planen. Wenn dasFutureselbst eine Aufgabe repräsentiert, dieSendist, dann kann sein interner Zustand über.await-Punkte hinweg zwischen Threads verschoben werden.- Aufnahme von Umgebungsvariablen: Wenn ein
async-Block Variablen aus seiner umgebenden Umgebung aufnimmt, werden diese Variablen Teil des Zustands desFuture. Wenn diese aufgenommenen Variablen nichtSendsind, dann kann dasFutureselbst nichtSendsein.
Hier treten die Compilerfehler oft auf. Sie erstellen einen async-Block, und dieser nimmt implizit etwas auf, das nicht Send (oder manchmal Sync) ist, was den Compiler daran hindert, die Threadsicherheit zu garantieren.
Häufige Szenarien, die zu "Nicht Send"-Fehlern führen
Betrachten wir einige häufige Fallstricke und ihre Lösungen.
Szenario 1: Aufnahme von Rc<T> oder RefCell<T>
Rc<T> (Reference Counted) und RefCell<T> (Reference Cell für innere Veränderlichkeit) sind für Single-Threaded-Szenarien konzipiert. Sie bieten keinen threadsicheren Zugriffsschutz.
Problematischer Code:
use std::rc::Rc; use std::cell::RefCell; use tokio::task; #[tokio::main] async fn main() { let counter = Rc::new(RefCell::new(0)); // Fehler: `Rc<RefCell<i32>>` kann nicht sicher zwischen Threads gesendet werden // `RefCell<i32>` kann nicht sicher zwischen Threads gesendet werden // `Rc<i32>` kann nicht sicher zwischen Threads gesendet werden let handle = task::spawn(async move { // Diese Closure nimmt `counter` durch Verschieben des Besitzes auf, // und da es sich um einen Async-Block handelt, muss das generierte Future Send sein. for _ in 0..100 { *counter.borrow_mut() += 1; } println!("Zähler in Aufgabe: {}", *counter.borrow()); }); handle.await.unwrap(); println!("Endgültiger Zähler: {}", *counter.borrow()); }
Warum es fehlschlägt: Rc erlaubt mehrere Besitzer innerhalb eines einzelnen Threads. RefCell erlaubt veränderliche Ausleihen innerhalb eines einzelnen Threads, ohne dass mut auf dem Besitzer erforderlich ist. Keines bietet Synchronisationsmechanismen für den Multi-Threaded-Zugriff, daher sind sie weder Send noch Sync. Die Funktion task::spawn, die für Multi-Threaded-Executoren entwickelt wurde, erfordert, dass ihr Future-Argument Send ist.
Lösung: Verwenden Sie Arc<T> und Mutex<T> / RwLock<T>
Für gemeinsamen Besitz über Threads hinweg und innere Veränderlichkeit verwenden Sie deren threadsichere Gegenstücke: Arc<T> (Atomic Reference Counted) und Mutex<T> (Mutual Exclusion Lock) oder RwLock<T> (Read-Write Lock).
use std::sync::{Arc, Mutex}; use tokio::task; #[tokio::main] async fn main() { let counter = Arc::new(Mutex::new(0)); // Arc für gemeinsamen Besitz, Mutex für innere Veränderlichkeit let counter_clone = Arc::clone(&counter); // Klonen Sie den Arc für die Aufgabe let handle = task::spawn(async move { for _ in 0..100 { // Sperren Sie den Mutex, um exklusiven Zugriff auf die inneren Daten zu erhalten let mut num = counter_clone.lock().unwrap(); *num += 1; } println!("Zähler in Aufgabe: {}", *counter_clone.lock().unwrap()); }); handle.await.unwrap(); println!("Endgültiger Zähler: {}", *counter.lock().unwrap()); }
Hier ist Arc<Mutex<i32>> Send, da Arc die Referenzzählung über Threads hinweg sicher handhabt und Mutex exklusiven Zugriff auf die i32-Daten sicherstellt, auch wenn mehrere Threads versuchen, sie zu ändern.
Szenario 2: Halten von nicht-Send-Typen über .await-Punkte hinweg
Manchmal ist die Ursache kein Typ wie Rc, sondern vielmehr ein temporärer nicht-Send-Wert, der implizit vom Zustandsautomaten des Async-Blocks über einen .await-Punkt hinaus erfasst wird. Normalerweise sind Betriebssystem-spezifische nicht-Send-Handle nur selten das Problem in idiomatischem Rust.
Problematischer Code (Konzeptionell):
Stellen Sie sich ein Szenario vor, in dem ein async-Block vorübergehend eine nicht-Send-Ressource erstellt, dann auf etwas wartet und dann diese nicht-Send-Ressource wieder verwendet.
// Dieses Beispiel ist konzeptionell, da die stdin/stdout-Handles von std::process::Child tatsächlich Sync sind // aber das Konzept veranschaulicht, wenn eine Ressource *nicht* Send wäre #[tokio::main] async fn main() { let config = String::from("some_config_data"); // Dieser Block könnte `config` aufnehmen, wenn `my_non_send_struct` es ausleihen würde, zum Beispiel. let _handle = tokio::spawn(async move { // Angenommen, `MyNonSendType` ist ein Typ, der NICHT Send ist. // Wenn eine Instanz von `MyNonSendType` hier erstellt würde // oder etwas aus der Umgebung ausleihen würde, das nicht Send war, // und dann würden wir einen await-Punkt erreichen... // let some_data = MyNonSendType::new(); // Hypothetischer nicht-Send-Typ println!("Vor await"); tokio::time::sleep(std::time::Duration::from_millis(10)).await; println!("Nach await. Das `Future` könnte Threads gewechselt haben."); // Wenn 'some_data' über den await hinweg erfasst wurde und es nicht Send ist, FEHLER! // some_data.do_something(); }); }
Warum es fehlschlägt (konzeptionell): Wenn ein async-Block (der zu einem Future desugiert) eine nicht-Send-Variable erfasst und sie über einen .await-Punkt hinweg festhält, wird der Compiler sich beschweren. Das liegt daran, dass der Executor das Future auf einem Thread aussetzen und nach Abschluss des await auf einem anderen Thread wieder aufnehmen kann. Wenn die nicht-Send-Variable Teil des Zustands des Future wäre, würde sie unsicher zwischen Threads verschoben.
Lösung: Nicht-Send-Variablen in lokale Bereiche verschieben oder sicherstellen, dass sie ordnungsgemäß synchronisiert sind.
- In den lokalen Bereich verschieben: Wenn die nicht-
Send-Variable nur vor oder nach dem.await-Punkt benötigt wird, stellen Sie sicher, dass ihre Lebensdauer nicht über den.await-Punkt hinausreicht. - Synchronisieren: Wenn die nicht-
Send-Variable über.await-Punkte und potenziell verschiedene Threads hinweg bestehen und zugegriffen werden muss, muss sie in threadsichere Primitive wieArc<Mutex<T>>oderArc<RwLock<T>>eingepackt werden.
Szenario 3: Closures und Fn vs. FnOnce vs. FnMut
Beim Spawnen von Async-Aufgaben ist es üblich, Closures zu übergeben. Das Schlüsselwort move für eine Closure ist entscheidend.
#[tokio::main] async fn main() { let mut my_data = 10; // Fehler: `my_data` wird implizit per Referenz erfasst, // und `my_data` ist nicht Sync (es ist veränderlich). // Das gespawnte Future würde `&mut i32` benötigen, um Send+Sync zu sein, was es nicht ist. // let handle = tokio::spawn(async { // println!("Daten aus innerem Task: {}", my_data); // my_data += 1; // Dies würde dazu führen, dass 'my_data' als `&mut` erfasst wird // }); // Korrekt: Verwenden Sie `move`, um den Besitz zu übertragen. let handle = tokio::spawn(async move { println!("Daten aus innerem Task: {}", my_data); my_data += 1; // Jetzt gehört `my_data` zur CLOSURE }); handle.await.unwrap(); // Kann hier nicht auf my_data zugreifen, da der Besitz an die gespawnte Aufgabe verschoben wurde. // println!("Daten in main: {}", my_data); }
Warum es wichtig ist: Ohne move würde my_data per Referenz (&mut my_data) erfasst. Eine veränderliche Referenz &mut T ist nur auf dem Thread gültig, auf dem sie erstellt wurde, was sie über Threads hinweg nicht-Send macht. Wenn Sie einen async-Block spawnen, können der äußere Task und der gespannte Task von verschiedenen Threads aus operieren.
Lösung: Verwenden Sie das Schlüsselwort move
Durch die Verwendung von move in async move { ... } wird der Besitz von my_data in das Future übertragen. Da i32 Send ist, ist auch das Future, das my_data enthält, Send. Wenn Sie Daten teilen müssen, während Sie im ursprünglichen Bereich darauf zugreifen können, verweisen Sie zurück auf Arc<Mutex<T>>.
Das for<'a> Future<&'a Context<'a>>-Problem
Dies ist ein fortgeschrittenerer Fall, der oft in generischem Async-Code oder beim Implementieren von Traits auftritt, die Lifetimes beinhalten. Wenn Ihr Async-Handler Daten mit einem bestimmten Lifetime, sagen wir 'a, ausleihen muss und diese Daten nicht Sync sind, dann kann das Future nicht Send sein. Dies geschieht, wenn der Executor das Future und seine ausgeliehenen Daten zwischen Threads verschieben muss, aber die ausgeliehenen Daten nicht sicher implizit gemeinsam genutzt werden können.
Fazit
Die Traits Send und Sync sind fundamentale Säulen der Threadsicherheitsgarantien von Rust und erstrecken sich tief in die asynchrone Programmierung. Wenn Ihr Async-Handler einen Fehler bezüglich Send oder Sync ausgibt, ist dies keine Behinderung, sondern eine hilfreiche Benachrichtigung des Compilers, die potenzielle Datenrennen und undefiniertes Verhalten verhindert. Indem Sie verstehen, dass async-Blöcke zu Futures desugieren, deren Zustand vom Executor zwischen Threads verschoben werden kann, können Sie korrekt identifizieren, wann Sie threadsichere Primitive wie Arc und Mutex anstelle ihrer Single-Threaded-Gegenstücke wie Rc und RefCell verwenden und das move-Schlüsselwort effektiv einsetzen. Die Annahme dieser Kernkonzepte ist der Schlüssel zum Schreiben robuster, leistungsfähiger und wirklich threadsicherer asynchroner Anwendungen in Rust. Ihr Compiler ist Ihr Freund und führt Sie zu sichererer Nebenläufigkeit.

