In der modernen Systemprogrammierung ist die Vereinbarkeit von Leistung und Speichersicherheit eine ständige Herausforderung. C++ war lange Zeit der unangefochtene König in diesem Bereich, aber in den letzten Jahren hat Rust begonnen, diese Position zu bedrohen. Das größte Merkmal von Rust ist die Garantie der Speichersicherheit zur Kompilierzeit ohne Garbage Collection (GC), was durch die Konzepte von “Ownership” (Eigentum) und “Borrowing” (Ausleihen) erreicht wird.
In diesem Artikel werden wir C++ Zeiger (rohe Zeiger, std::unique_ptr, std::shared_ptr) und das Rust-Ownership-Modell detailliert vergleichen. Wir werden anhand von Codebeispielen und Diagrammen ausführlich erklären, wie der Rust-Compiler (Borrow Checker) Use-After-Free (Verwendung nach Freigabe) und Datenrennen (Data Races) verhindert.
1. Grundlagen der Speicherverwaltung: Stack und Heap
Um die Grundlagen der Speicherverwaltung zu verstehen, lassen Sie uns zunächst einen Blick darauf werfen, wie Programme den Speicher nutzen. Der Speicherbereich wird grob in “Stack” und “Heap” unterteilt.
Stack
Dies ist der Bereich, in dem lokale Variablen bei Funktionsaufrufen abgelegt werden. Er hat eine LIFO-Struktur (Last-In-First-Out) und die Speicherzuweisung und -freigabe ist sehr schnell. Hier werden nur Daten platziert, deren Größe zur Kompilierzeit bestimmt werden kann.
Heap
Hier werden Daten platziert, deren Größe zur Laufzeit dynamisch bestimmt wird, oder Daten, die über den Gültigkeitsbereich einer Funktion hinaus existieren müssen. Auf sie wird über Zeiger (oder Referenzen) zugegriffen.
In C++ und Rust, die keine Garbage Collection haben, kann der Verwaltungsaufwand für den Heapspeicher als Formel wie folgt modelliert werden. Wenn die Gesamtzahl der Objekte $N$ ist, die durchschnittliche Zeit für die Allokation $T_{alloc}$ und die durchschnittliche Zeit für die Deallokation $T_{dealloc}$ ist, dann ist der Gesamtaufwand für die Speicherverwaltung $C_{memory}$:
$$ C_{memory} = \sum_{i=1}^{N} (T_{alloc, i} + T_{dealloc, i}) + O_{sync} $$Hierbei ist $O_{sync}$ der Overhead für die gegenseitige Ausschließung (Mutex- oder atomare Operationen) in einer Multithread-Umgebung. Da Rust den Zeitpunkt der Speicherfreigabe zur Kompilierzeit bestimmt, führt es $T_{dealloc}$ zu einem zuverlässigen und sicheren Zeitpunkt aus, während die Durchsatzminderung (Stop-The-World) durch die Garbage Collection zur Laufzeit auf null reduziert wird.
2. C++ Zeiger: Der Kompromiss zwischen Freiheit und Gefahr
Werfen wir einen Blick auf die Entwicklung der Speicherverwaltung in C++.
Die Ära der rohen Zeiger (Raw Pointers) und ihre Probleme
Rohe Zeiger (*), die von der Sprache C übernommen wurden, bieten ultimative Freiheit, sind aber gleichzeitig eine Brutstätte für die folgenden schwerwiegenden Bugs:
- Speicherleck (Memory Leak): Vergessen, mit
newzugewiesenen Speicher mitdeletefreizugeben. - Hängender Zeiger (Dangling Pointer): Zugriff auf einen Zeiger nach der Speicherfreigabe (nach
delete). - Doppelte Freigabe (Double Free): Zweimaliges Aufrufen von
deletefür denselben Speicherbereich.
| |
Die Einführung von RAII und Smart Pointern (ab C++11)
Seit C++11 wurden Smart Pointer, die auf dem RAII-Konzept (Resource Acquisition Is Initialization) basieren, standardisiert, und die direkte Verwendung von rohen Zeigern wird nicht mehr empfohlen.
std::unique_ptr
Ein Zeiger, der exklusives Eigentum (Ownership) ausdrückt. Der Speicher wird automatisch freigegeben, wenn der Gültigkeitsbereich (Scope) verlassen wird. Er kann nicht kopiert werden; nur das “Verschieben” (Move) des Eigentums ist möglich (unter Verwendung von std::move).
| |
std::shared_ptr
Ein Zeiger, der es mehreren Zeigern ermöglicht, dasselbe Objekt zu teilen. Er verwendet Referenzzählung (Reference Counting), um den Speicher freizugeben, wenn der Zähler 0 erreicht. Da atomare Inkrementierungs- und Dekrementierungsoperationen erforderlich sind, entsteht ein gewisser Leistungs-Overhead (entsprechend dem oben genannten $O_{sync}$).
3. Rusts Ownership (Eigentum): Ein Paradigmenwechsel
Rust hat das Konzept von std::unique_ptr aus C++ als Kern seiner Sprachspezifikation übernommen und verfügt über ein noch strengeres “Ownership-Modell”.
Die 3 Regeln von Ownership
Das Ownership-System von Rust basiert auf den folgenden drei sehr einfachen Regeln:
- Jeder Wert in Rust hat eine Variable, die als sein Eigentümer (Owner) bezeichnet wird.
- Es kann immer nur einen Eigentümer zur gleichen Zeit geben.
- Wenn der Eigentümer den Gültigkeitsbereich (Scope) verlässt, wird der Wert verworfen.
In Rust werden Ressourcen standardmäßig “verschoben” (moved). Das Eigentum wird durch Zuweisungsoperationen verschoben, ohne dass std::move wie in C++ explizit angegeben werden muss.
| |
Diese Funktion, den Zugriff auf Variablen nach einem Move zur Kompilierzeit zu verhindern, ist einer der Gründe, warum Rust sicherer ist als std::unique_ptr in C++.
4. Borrowing (Ausleihen) und Referenzen
Wenn man das Eigentum ständig verschiebt, müsste man es jedes Mal zurückgeben lassen, wenn man einen Wert an eine Funktion übergibt, was sehr unpraktisch ist. Hier kommt das “Borrowing” (Ausleihen) ins Spiel. Es entspricht Zeigern oder Referenzen in C++.
In Rust gibt es zwei Arten von Borrowing:
- Unveränderliche Referenz (Immutable Reference):
&T(ähnlich wieconst T&in C++) - Veränderliche Referenz (Mutable Reference):
&mut T(ähnlich wieT&in C++)
Die unerbittlichen Regeln des Borrow Checkers
Der Rust-Compiler enthält einen “Borrow Checker”, der die Gültigkeit von Referenzen überprüft. Der Borrow Checker erzwingt die folgenden strengen Regeln:
In jedem beliebigen Gültigkeitsbereich kann nur eine der folgenden Bedingungen existieren:
- Genau eine veränderliche Referenz (
&mut T)- Mehrere unveränderliche Referenzen (
&T)
Dies wird als das Prinzip “Multiple Readers XOR Single Writer (MRSW)” bezeichnet. Es kann durch ein mathematisches exklusives ODER (XOR) ausgedrückt werden. Für einen Zustand $S$ müssen die Anzahl der unveränderlichen Referenzen $N_r$ und die Anzahl der veränderlichen Referenzen $N_w$ die folgende Einschränkung erfüllen:
$$ (N_r \ge 0 \land N_w = 0) \oplus (N_r = 0 \land N_w = 1) $$Diese Regel eliminiert Datenrennen (Data Races) zur Kompilierzeit vollständig. Ein Datenrennen tritt auf, wenn ① zwei oder mehr Zeiger gleichzeitig auf dieselben Daten zugreifen, ② mindestens einer davon schreibt und ③ kein Synchronisationsmechanismus vorhanden ist. Rust verhindert Datenrennen im Vorfeld, indem es die Bedingung ② zur Kompilierzeit bricht.
| |
5. Verhinderung der Iterator-Ungültigmachung (Iterator Invalidation)
Als konkretes Beispiel, bei dem die Leistungsfähigkeit des Borrow Checkers am deutlichsten wird, betrachten wir den klassischen Bug der “Iterator-Ungültigmachung”.
Iterator-Ungültigmachung in C++ (Laufzeitabsturz)
Wenn ein std::vector in C++ innerhalb einer Schleife geändert wird, besteht die Möglichkeit, dass der dahinterliegende Speicher neu zugewiesen (reallocated) wird, wodurch Referenzen zu hängenden Zeigern (Dangling Pointers) werden.
| |
Schutz zur Kompilierzeit durch Rust
Lassen Sie uns genau dieselbe Logik in Rust schreiben.
| |
Da Rust auf Compiler-Ebene verbietet, “einen Wert zu ändern (veränderlich auszuleihen), während er gelesen wird (unveränderlich ausgeliehen ist)”, werden fatale Bugs wie Use-After-Free oder Iterator-Ungültigmachung zur Kompilierzeit zuverlässig abgefangen.
6. Geteiltes Eigentum (Shared Ownership) in Rust: Rc und Arc
Geteiltes Eigentum, das dem std::shared_ptr in C++ entspricht, ist auch in Rust verfügbar, jedoch gibt es eine klare Typentrennung für Single-Threaded- und Multi-Threaded-Anwendungen.
Für Single-Thread: Rc<T> (Reference Counted)
Rc<T> ist ein nicht-thread-sicherer Smart Pointer mit Referenzzählung. Da er Inkrementierungs- und Dekrementierungsoperationen ohne atomare Befehle durchführt, ist er innerhalb eines einzelnen Threads sehr schnell. Wenn Sie jedoch versuchen, ihn an einen anderen Thread zu senden, führt dies zu einem Kompilierfehler (da er das Send-Trait nicht implementiert).
Für Multi-Thread: Arc<T> (Atomic Reference Counted)
Wenn Daten zwischen Threads geteilt werden sollen, wird Arc<T> verwendet, das atomare Inkrementierungen und Dekrementierungen durchführt. Es verursacht vergleichbare Kosten wie std::shared_ptr in C++.
Darüber hinaus tritt in C++ ein Datenrennen auf, wenn mehrere Threads gleichzeitig in eine von std::shared_ptr gemeinsam genutzte Variable schreiben. Um dies zu verhindern, muss std::mutex manuell und korrekt verwendet werden.
Im Gegensatz dazu kann in Rust die innere Struktur von Arc<T> allein nicht geändert werden. Wenn Änderungen erforderlich sind, muss es mit einem Mutex, d. h. Mutex<T>, kombiniert werden.
| |
Bemerkenswert ist, dass Mutex<T> in Rust nicht nur ein einfacher Sperrmechanismus ist, sondern “die zu schützenden Daten als Typ kapselt”. Dies verhindert auf Compiler-Ebene vollständig den Fehler, “auf Daten zuzugreifen und dabei zu vergessen, die Sperre zu erlangen”. Es ist so konzipiert, dass man ohne das Erlangen der Sperre (lock()) keine Zugriffsrechte (Referenz) auf die darin enthaltenen Daten erhält.
Fazit: “Vorabprüfung” durch den Compiler oder “Eigenverantwortung” durch den Entwickler
Zeiger und Smart Pointer in C++ bieten Entwicklern ein hohes Maß an Kontrolle und Leistung, aber ihre korrekte Verwendung hängt von der Disziplin des Entwicklers ab. Obwohl C++ durch die Einführung von RAII und std::unique_ptr drastisch sicherer geworden ist, kann es “undefiniertes Verhalten” wie den Zugriff nach einem Move oder die Iterator-Ungültigmachung auf Sprachebene nicht vollständig verhindern.
Auf der anderen Seite erkennt Rust diese Fehler zur Kompilierzeit anstatt zur Laufzeit, indem es die Regeln von “Ownership” und “Borrowing” in den Compiler integriert. Die starke Garantie, dass “wenn es kompiliert, speichersicher ist”, ist der Hauptgrund, warum Rust in der Systemprogrammierung schnell an Popularität gewinnt.
Der Kampf mit dem Borrow Checker von Rust (“Fighting the borrow checker”) ist für Anfänger eine große Hürde, aber letztendlich übernimmt der Compiler nur streng die komplexe Berechnung der “Verfolgung der Lebensdauer von Zeigern”, die C++ Programmierer ursprünglich in ihren Köpfen durchführten.
Wenn man Rust lernt, nachdem man die Freiheit und die Gefahren von C++ Zeigern verstanden hat, wird man die Philosophie hinter dem Ownership-Modell und das “Warum es so entworfen wurde” tiefgründiger verstehen können.
Dieser Artikel ist eine vergleichende Betrachtung der Speicherverwaltungsmethoden in C++ und Rust. Wir hoffen, dass er als Referenz für die Auswahl der geeigneten Sprache entsprechend den Anforderungen des jeweiligen Projekts dient.
