Rust’s Atomics Explained: Die Unteilbarkeiten der Nebenläufigkeit
Emily Parker
Product Engineer · Leapcell

Atomare Typen und Atomare Operationen
Ein Atom bezieht sich auf eine Reihe von Maschinenbefehlen, die von der CPU nicht unterbrochen oder per Kontextwechsel verändert werden können. Diese Befehle bilden, wenn sie zusammengefasst werden, atomare Operationen. Wenn ein Kern auf einer Multi-Core-CPU mit der Ausführung einer atomaren Operation beginnt, pausiert er Speicheroperationen auf anderen CPU-Kernen, um sicherzustellen, dass die atomare Operation nicht beeinträchtigt wird.
Eine atomare Operation bezieht sich auf eine oder mehrere Operationen, die unteilbar und nicht unterbrechbar sind. In der nebenläufigen Programmierung müssen bestimmte Garantien auf CPU-Ebene bereitgestellt werden, um sicherzustellen, dass eine Operationsfolge atomar ist. Eine atomare Operation kann aus einem einzigen Schritt oder aus mehreren Schritten bestehen, aber die Abfolge dieser Schritte darf nicht unterbrochen werden, und ihre Ausführung darf nicht durch einen anderen Mechanismus unterbrochen werden.
Hinweis: Da atomare Operationen direkt von CPU-Befehlen unterstützt werden, sind sie im Allgemeinen viel leistungsfähiger als Sperren oder Message Passing. Im Vergleich zu Sperren erfordern atomare Typen von Entwicklern keine Verwaltung von Sperrenakquisition und -freigabe und unterstützen auch Operationen wie Modifikation und Lesen mit höherer nebenläufiger Leistung. Fast alle Programmiersprachen unterstützen atomare Typen.
Atomare Typen sind Datentypen, die Entwicklern helfen, atomare Operationen einfacher zu implementieren. Atomare Typen sind sperrfrei, aber sperrfrei bedeutet nicht wartefrei. Intern verwenden atomare Typen eine CAS-Schleife, sodass bei starker Konkurrenz immer noch Wartezeiten erforderlich sind! Trotzdem sind sie im Allgemeinen besser als Sperren.
Hinweis: CAS steht für Compare and Swap (Vergleichen und Austauschen). Es liest eine bestimmte Speicheradresse mit einem einzigen Befehl und prüft, ob ihr Wert mit einem gegebenen erwarteten Wert übereinstimmt. Wenn ja, aktualisiert es den Wert auf einen neuen Wert.
Als Nebenläufigkeitsprimitive sind atomare Operationen der Eckpfeiler für die Implementierung aller anderen Nebenläufigkeitsprimitive. Fast alle Programmiersprachen unterstützen atomare Typen und Operationen. So bietet beispielsweise Java viele atomare Typen in java.util.concurrent.atomic
, Go bietet Unterstützung über das Paket sync/atomic
, und Rust ist da keine Ausnahme.
Hinweis: Atomare Operationen sind ein CPU-Level Konzept. In Programmiersprachen gibt es ein ähnliches Konzept, das als Concurrency Primitives bezeichnet wird. Dies sind Funktionen, die vom Kernel bereitgestellt werden, um extern aufgerufen zu werden, und solche Funktionen dürfen während der Ausführung nicht unterbrochen werden.
Atomare Primitiven in Rust
In Rust befinden sich atomare Typen im Modul std::sync::atomic
.
Die Dokumentation für dieses Modul beschreibt atomare Typen wie folgt: Atomare Typen in Rust bieten primitive Shared-Memory-Kommunikation zwischen Threads und dienen als Grundlage für den Aufbau anderer Nebenläufigkeitstypen.
Das Modul std::sync::atomic
bietet derzeit die folgenden 12 atomaren Typen:
AtomicBool
AtomicI8
AtomicI16
AtomicI32
AtomicI64
AtomicIsize
AtomicPtr
AtomicU8
AtomicU16
AtomicU32
AtomicU64
AtomicUsize
Atomare Typen unterscheiden sich nicht wesentlich von regulären Typen – zum Beispiel AtomicBool
und bool
– außer dass erstere in Multithread-Kontexten verwendet werden können, während letztere besser für die Singlethread-Nutzung geeignet sind.
Nehmen wir AtomicI32
als Beispiel. Es ist als Struktur definiert und enthält die folgenden Methoden im Zusammenhang mit atomaren Operationen:
pub fn fetch_add(&self, val: i32, order: Ordering) -> i32 - Führt eine Addition (oder Subtraktion) auf dem atomaren Typ aus pub fn compare_and_swap(&self, current: i32, new: i32, order: Ordering) -> i32 - CAS (in Rust 1.50 veraltet, ersetzt durch compare_exchange) pub fn compare_exchange(&self, current: i32, new: i32, success: Ordering, failure: Ordering) -> Result<i32, i32> - CAS pub fn load(&self, order: Ordering) -> i32 - Liest den Wert aus dem atomaren Typ pub fn store(&self, val: i32, order: Ordering) - Schreibt einen Wert in den atomaren Typ pub fn swap(&self, val: i32, order: Ordering) -> i32 - Tauscht Werte aus
Wie Sie sehen, benötigt jede Methode einen Parameter Ordering
. Ordering
ist ein Enum, das die Stärke der Memory Barrier für diese Operation darstellt und zur Steuerung der Memory Ordering atomarer Operationen verwendet wird.
Hinweis: Memory Ordering bezieht sich auf die Reihenfolge, in der die CPU auf den Speicher zugreift, was beeinflusst werden kann durch:
- Die Reihenfolge der Anweisungen im Code
- Compiler-Optimierungen, die den Speicherzugriff zur Kompilierzeit neu anordnen (Memory Reordering)
- CPU-Level Caching-Mechanismen zur Laufzeit, die die Zugriffsreihenfolge stören können
pub enum Ordering { Relaxed, Release, Acquire, AcqRel, SeqCst, }
In Rust stellen die Enum-Werte in Ordering
Folgendes dar:
- Relaxed – Die lockerste Regel, die dem Compiler oder der CPU keine Einschränkungen auferlegt und maximale Neuanordnung ermöglicht
- Release – Setzt eine Memory Barrier, um sicherzustellen, dass alle Operationen davor vor dieser stattfinden. Operationen danach können davor neu angeordnet werden (wird für Schreibvorgänge verwendet)
- Acquire – Setzt eine Memory Barrier, um sicherzustellen, dass alle Operationen danach nach dieser stattfinden. Operationen davor können danach neu angeordnet werden. Wird häufig zusammen mit Release in anderen Threads verwendet (wird für Lesevorgänge verwendet)
- AcqRel – Eine Kombination aus Acquire und Release, die beide Richtungen der Memory Ordering sicherstellt. Für
load
verhält es sich wie Acquire, fürstore
wie Release. Wird oft in Methoden wiefetch_add
verwendet - SeqCst (Sequentiell Konsistent) – Eine stärkere Version von AcqRel. Es ist innerhalb eines Threads keine Neuanordnung von Operationen um eine atomare
SeqCst
-Operation herum erlaubt. Sie garantiert auch eine konsistente globale Reihenfolge über alle Threads für alleSeqCst
-Operationen. Obwohl sie eine geringere Leistung bietet, ist sie die sicherste Option.
Mit dem Ordering
-Enum können Entwickler das zugrunde liegende Memory Ordering-Verhalten anpassen.
Hinweis: Was ist Memory Ordering? Aus Wikipedia: Memory Ordering ist die Reihenfolge, in der eine CPU auf den Hauptspeicher zugreift. Sie kann zur Kompilierzeit vom Compiler oder zur Laufzeit von der CPU bestimmt werden. Sie spiegelt die Neuanordnung von Speicheroperationen und die Out-of-Order-Ausführung wider, die darauf ausgelegt sind, die Busbandbreitennutzung zwischen verschiedenen Speicherkomponenten zu maximieren. Die meisten modernen Prozessoren führen Anweisungen Out-of-Order aus. Daher sind Memory Barriers erforderlich, um die Synchronisation zwischen Threads sicherzustellen.
Um Memory Ordering besser zu verstehen, stellen Sie sich zwei Threads vor, die auf einem
AtomicI32
arbeiten. Angenommen, der Anfangswert ist 0. Ein Thread führt einen Schreibvorgang durch und aktualisiert den Wert auf 10, und der andere führt einen Lesevorgang durch. Wenn der Schreibvorgang vor dem Lesevorgang abgeschlossen ist, sieht der lesende Thread dann definitiv 10? Die Antwort ist nicht unbedingt. Aufgrund von Compiler-Optimierungen und CPU-Strategien kann sich der aktualisierte Wert noch im Register befinden und noch nicht in den Speicher geschrieben werden. Um die Register-Speicher-Synchronisation sicherzustellen, ist Memory Ordering erforderlich.
Release
stellt sicher, dass der Registerwert in den Speicher geschrieben wird.Acquire
ignoriert das lokale Register und liest direkt aus dem Speicher. Wenn wir beispielsweisestore
mitOrdering::Release
aufrufen, gefolgt von einemload
mitOrdering::Acquire
, können wir sicherstellen, dass der lesende Thread den aktuellsten Wert liest.
Verwenden von Atomic in Multithreading
Da alle atomaren Typen das Sync
-Trait implementieren, ist das Teilen atomarer Variablen über Threads hinweg sicher. Da atomare Typen selbst jedoch keinen Sharing-Mechanismus bereitstellen, besteht der übliche Ansatz darin, sie in einen atomar referenzgezählten Smart Pointer, Arc
, zu platzieren. Nachfolgend finden Sie ein einfaches Spinlock-Beispiel aus der offiziellen Dokumentation:
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; fn main() { // Create a lock using an atomic type and share ownership via Arc let spinlock = Arc::new(AtomicUsize::new(1)); // Increase the reference count let spinlock_clone = spinlock.clone(); let thread = thread::spawn(move || { // SeqCst ordering: store (write) operation uses release semantics // meaning operations before the store cannot be reordered after it spinlock_clone.store(0, Ordering::SeqCst); }); // Use a while loop to wait for the critical section to become available // SeqCst ordering: load (read) operation uses acquire semantics // meaning operations after the load cannot be reordered before it // The write instruction from the thread above ensures that // subsequent reads/writes will not be reordered before it while spinlock.load(Ordering::SeqCst) != 0 {} if let Err(panic) = thread.join() { println!("Thread had an error: {:?}", panic); } }
Hinweis: Ein Spinlock bezieht sich auf einen Sperrmechanismus, bei dem ein Thread, der versucht, eine Sperre zu erhalten, die bereits von einem anderen Thread gehalten wird, diese nicht sofort erhalten kann. Stattdessen wartet der Thread und versucht es nach einiger Zeit erneut. Der Begriff „Spin“ leitet sich von der Tatsache ab, dass die CPU in eine Busy-Wait-Schleife eintritt (wie in der obigen
while
-Schleife), um darauf zu warten, dass der kritische Abschnitt verfügbar wird.Spinlocks reduzieren die Kosten für die Thread-Blockierung und eignen sich für Szenarien mit geringer Konfliktdichte und sehr kurzen Sperrzeiten. In Fällen hoher Konfliktdichte oder langer Ausführung kritischer Abschnitte können die CPU-Kosten für das Spinning jedoch die Kosten für das Anhalten des Threads überwiegen. Dies könnte CPU-Zyklen verschwenden und die Systemleistung beeinträchtigen, da Spinning-Threads verhindern, dass andere Threads CPU-Zeit erhalten.
Das obige Beispiel zeigt, wie man ein Spinlock mit Ordering::SeqCst
für Memory Ordering implementiert. Versuchen wir nun, ein benutzerdefiniertes Spinlock zu implementieren:
use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }; use std::thread; use std::time::Duration; struct SpinLock { lock: AtomicBool, } impl SpinLock { pub fn new() -> Self { Self { lock: AtomicBool::new(false), } } pub fn lock(&self) { while self .lock .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() // Attempt to acquire lock; if it fails, keep spinning { // Because CAS is expensive, on failure we simply load the lock status // and retry CAS only when we detect the lock has been released while self.lock.load(Ordering::Relaxed) {} } } pub fn unlock(&self) { // Release the lock self.lock.store(false, Ordering::Release); } } fn main() { let spinlock = Arc::new(SpinLock::new()); let spinlock1 = spinlock.clone(); let thread = thread::spawn(move || { // Child thread acquires lock using compare_exchange spinlock1.lock(); thread::sleep(Duration::from_millis(100)); println!("do something1!"); // Child thread releases lock spinlock1.unlock(); }); thread.join().unwrap(); // Main thread acquires lock spinlock.lock(); println!("do something2!"); // Main thread releases lock spinlock.unlock(); }
In der obigen benutzerdefinierten Spinlock-Implementierung ist die Sperre im Wesentlichen ein einzelner atomarer Typ: AtomicBool
, mit einem Anfangswert von false
.
Beim Aufruf der lock
-Methode zum Erhalten der Sperre wird die atomare Operation compare_exchange
(CAS) verwendet. Wenn CAS fehlschlägt, dreht sich der Thread in einer while
-Schleife. Hier gibt es eine kleine Leistungsoptimierung: Da CAS relativ teuer ist, tritt der Thread nach einem Fehler in eine einfache Schleife mit einem einfachen load
ein, um den Sperrstatus zu überprüfen. Nur wenn erkannt wird, dass die Sperre freigegeben wurde, wird CAS erneut versucht. Dies ist effizienter.
Beim Aufruf von unlock
wird AtomicBool
einfach mit store
mit Ordering::Release
auf false
gesetzt, wodurch der Wert aus dem Register in den Speicher geschrieben wird. Wenn sich ein Thread in der lock
-Methode dreht und compare_exchange
mit Ordering::Acquire
ausführt, ignoriert er seinen aktuellen Registerwert und ruft den neuesten Wert aus dem Speicher ab. Wenn er false
sieht, ist CAS erfolgreich und der Thread erhält die Sperre.
Können Atomare Typen Sperren Ersetzen?
Angesichts der Leistungsfähigkeit atomarer Typen, können diese traditionelle Sperren vollständig ersetzen? Die Antwort lautet: Nein.
Hier sind die Gründe:
- Für komplexe Szenarien ist die Verwendung von Sperren einfacher und weniger fehleranfällig.
- Das Modul
std::sync::atomic
bietet nur atomare Operationen für numerische Typen, wie z. B.AtomicBool
,AtomicIsize
,AtomicUsize
usw., während Sperren auf jede Art von Daten angewendet werden können. - In einigen Situationen sind Sperren notwendig, um sie mit anderen Primitiven zu koordinieren, wie z. B.
Mutex
,RwLock
,Condvar
usw.
Anwendungsfälle für Atomare Typen
Obwohl Atomic
-Typen in der Praxis von alltäglichen Anwendungsentwicklern möglicherweise nicht häufig verwendet werden, werden sie sehr häufig von Entwicklern hochperformanter Bibliotheken und Maintainern von Standardbibliotheken verwendet. Atomare Operationen sind das Fundament von Nebenläufigkeitsprimitiven, und darüber hinaus gibt es mehrere anwendbare Szenarien:
- Sperrfreie Datenstrukturen
- Globale Variablen, wie z. B. eine globale Auto-Increment-ID (die in einem späteren Abschnitt behandelt wird)
- Threadübergreifende Zähler, z. B. zum Sammeln von Metriken
Dies sind nur einige Beispiele dafür, wo Atomic
-Typen verwendet werden können. In realen Szenarien liegt es am Entwickler, die Bewertung vorzunehmen und auf der Grundlage der tatsächlichen Bedürfnisse zu entscheiden.
Zusammenfassung
Ein Atom ist wie die unteilbare Einheit in der Biologie – die kleinste Einheit, die nicht weiter geteilt werden kann. Eine atomare Operation ist „eine Operation oder Reihe von Operationen, die nicht unterbrochen werden können“. Atomare Typen sind Datentypen, die Entwicklern helfen, solche atomaren Operationen einfacher zu implementieren. Nebenläufigkeitsprimitive sind Kernel-Level-Funktionen, die extern aufgerufen werden können, und ihre Ausführung darf nicht unterbrochen werden.
Atomic
-Typen sind sperrfrei; sie verwenden intern eine CAS-Schleife und erfordern nicht, dass der Entwickler die Sperrung und Entsperrung verwaltet. Sie unterstützen atomare Operationen wie das Ändern und Lesen von Werten. Da diese Operationen von CPU-Befehlen unterstützt werden, sind sie viel leistungsfähiger als Sperren oder Message Passing.
Atomare Operationen müssen zusammen mit Memory Ordering (Ordering
) verwendet werden. Dieses Enum ermöglicht es Entwicklern, das zugrunde liegende Memory Ordering-Verhalten anzupassen. Da Atomic
-Typen in vielen Szenarien eine bessere Leistung als Sperren bieten, werden sie in Rust häufig verwendet – zum Beispiel als globale Variablen oder gemeinsam genutzte Variablen über Threads hinweg. Sie können Sperren jedoch nicht vollständig ersetzen, da Sperren einfacher und breiter anwendbar sind.
Atomare Operationen können in die folgenden fünf Kategorien eingeteilt werden:
fetch_add
– Führt eine Addition (oder Subtraktion) auf dem atomaren Typ auscompare_and_swap
undcompare_exchange
– Vergleicht Werte und tauscht sie aus, wenn sie gleich sindload
– Liest den Wert aus dem atomaren Typstore
– Schreibt einen Wert in den atomaren Typswap
– Tauscht Werte aus
Wir sind Leapcell, Ihre erste Wahl für das Hosten von Rust-Projekten.
Leapcell ist die Serverless-Plattform der nächsten Generation für Webhosting, asynchrone Aufgaben und Redis:
Multi-Sprachen Unterstützung
- 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 $ 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.
- Echtzeit-Metriken und -Protokollierung für umsetzbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Automatische Skalierung zur einfachen Bewältigung hoher Nebenläufigkeit.
- Keine betrieblichen Gemeinkosten — konzentrieren Sie sich einfach auf das Bauen.
Erfahren Sie mehr in der Dokumentation!
Folgen Sie uns auf X: @LeapcellHQ