Zum Inhalt springen

Smart Pointer in Rust

In den meisten Sprachen mit Garbage Collector ist ein Speichermodell üblich, das man als “Sea of Objects” beschreibt. Objekte schwimmen im Speicher. Objekt A verweist auf B, B auf C, und C vielleicht wieder zurück auf A. Es ist ein wilder, unstrukturierter Graph.

Sea of Objects

Standardmäßig erzwingt das Ownership-Modell von Rust Baumstrukturen. Mit normalen Referenzen (& oder &mut) oder Box<T> ist es nur möglich, Datenstrukturen als Baum abzubilden (genauer gesagt als einen gerichteten azyklischen Graphen).

Rust Baum

Um andere Datenstrukturen zu implementieren, benötigen wir Smart Pointers.

Smart Pointers sind Datentypen, die sich wie normale Zeiger verhalten, aber zusätzlichen Schutz bieten. Ihre Hauptaufgabe ist es, dynamischen Speicher (Heap) automatisch zu verwalten. Im Gegensatz zu “rohen Zeigern” (Raw Pointers) nutzen sie das RAII-Prinzip (Resource Acquisition Is Initialization):

  • Erstellung: Speicher wird reserviert.
  • Out of scope: Speicher wird automatisch freigegeben.

Sie verhindern klassische Fehler wie Memory Leaks oder Dangling Pointer.

Ein Vergleich der Ansätze:

  • C: Manuelle Verwaltung: Die Speicherverwaltung erfolgt vollständig manuell über Rohzeiger und Funktionen wie malloc oder free. Dies gewährt dem Entwickler die maximale Kontrolle über das Speicherlayout und die Lebensdauer der Objekte. Allerdings ist die Sicherheit gering, da das System sehr anfällig für schwerwiegende Fehler wie Speicherlecks und Pufferüberläufe ist.
  • C++: RAII und Standard-Smart-Pointer: Durch das RAII-Konzept wird die Ressourcenfreigabe automatisch an die Lebensdauer von Objekten gebunden. Das Modell bietet weiterhin eine hohe Kontrolle, automatisiert jedoch die Bereinigung effizient. Die Sicherheit ist deutlich höher als in C, wenngleich Referenzzyklen und die falsche Nutzung von Rohzeigern weiterhin Risiken bergen.
  • Rust: Das System basiert auf strengen Ownership- und Borrowing-Regeln, die der Compiler bereits vor der Ausführung erzwingt. Dies garantiert maximale Speichersicherheit ohne Garbage Collector.

Box<T> speichert die Daten auf dem Heap statt dem Stack. Die Box selbst ist ein Pointer, der auf dem Stack gespeichert wird. Dies ist nützlich in folgenden Szenarien:

Wenn die Größe eines Typen zu Kompilierzeit unbekannt ist

Abschnitt betitelt „Wenn die Größe eines Typen zu Kompilierzeit unbekannt ist“

Der Typ Vehicle ist im folgenden Beispiel nur ein Trait ohne konkrete Größe. Jede Variable, die auf dem Stack gespeichert werden soll, muss aber eine bestimmbare Größe haben.

trait Vehicle {
fn drive(&self);
}
struct Truck;
impl Vehicle for Truck {
fn drive(&self) { println!("Brumm") }
}
fn main() {
// dyn Vehicle hat keine Größe, muss also auf den Heap
let t = Box::new(Truck);
t.drive();
}

Wenn man den Typen in einer Box speichert, so wird die Box auf dem Stack abgelegt. Diese hat eine feste Größe (die des Pointers) und kann daher direkt auf dem Stack gespeichert werden.

struct List {
value: i32,
next: Option<Box<List>>;
}

Ohne Box könnte der Compiler die Größe von List nicht berechnen (Liste enthält Liste, die Liste enthält…). Da die Box eine feste Größe hat, ist der Compiler nun zufrieden.


Rc<T> ermöglicht Shared Ownership (geteilten Besitz). Mehrere Variablen können denselben Wert besitzen. Der Speicher wird erst freigegeben, wenn der letzte Owner verschwindet.

Klassisches Borrowing: Hier wird a am Ende des Blocks gedroppt. b und c würden auf bereinigten Speicher zeigen.

Speicher Borrow

Shared Ownership (Rc): Hier zeigen a, b und c auf denselben Speicher im Heap. Erst wenn alle drei weg sind, wird der String gelöscht.

fn main() {
let a = Rc::new(String::new()); // strong_count 1
let b = Rc::clone(&a); // strong_count 2
let c = Rc::clone(&a); // strong_count 3
drop(a); // strong_count 2
println!("{c}"); // c hat noch Ownership
}

Speicher Multiple Ownership

Rc<T> ist nicht threadsicher. Wenn zwei Threads gleichzeitig den Zähler erhöhen, kann dies zu Speicherfehlern führen. Dafür gibt es Arc<T> (Atomic Rc). Die API ist identisch, aber Arc nutzt spezielle CPU-Befehle, die sicherstellen, dass der Zähler auch bei parallelem Zugriff korrekt bleibt.

Under the hood: Rc und Arc Warum nicht immer Arc nutzen? Weil Thread-Sicherheit Leistung kostet.

  • Rc Assembly: inc qword ptr [rdi]
    • Dauer: ~1 CPU-Zyklus
    • Simples Hochzählen
  • Arc Assembly: lock xadd qword ptr [rdi], rax
    • Dauer: ~20-100+ CPU-Zyklen.
    • Das lock Präfix zwingt die CPU, Caches zu synchronisieren, was bremst.

Das Problem ohne lock: Wenn Thread A und Thread B gleichzeitig x = x + 1 rechnen wollen:

  1. Beide lesen 0.
  2. Beide rechnen lokal 0 + 1.
  3. Beide schreiben 1. Ergebnis ist 1, sollte aber 2 sein. Arc verhindert das.

lock xadd führt diese gesamte Operation atomar aus, das heißt quasi in einem Rutsch, ohne dass ein anderer Thread dazwischen funken kann.


Rc und Arc ermöglichen geteiltes Ownership. Sie erlauben nur unveränderliche Referenzen auf die Daten, um bei gemeinsamen gleichzeitigem Verändern Race Conditions zu vermeiden.

RefCell<T> ist die Lösung, um Daten zu verändern, selbst wenn man nur eine immutable Referenz darauf besitzt. Dieses Design-Pattern nennt sich Interior Mutability.

fn main() {
let x = 5;
let y = &mut x; // Fehler!
}
fn main() {
let x = 5;
let y = RefCell::new(x);
}

Dies ist die häufigste Kombination in Rust:

  • Rc sorgt dafür, dass die Daten mehrere Besitzer haben.
  • RefCell sorgt dafür, dass wir die Daten ändern können.
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
let log = Rc::new(RefCell::new(String::new()));
let a = Rc::clone(&log);
let b = Rc::clone(&log);
let c = Rc::clone(&log);
{
let mut mut_refa = a.borrow_mut();
mut_refa.push_str(" -> A war hier");
}
{
let mut mut_refb = b.borrow_mut();
mut_refb.push_str(" -> B war hier");
}
println!("Log Ergebnis: {}", c.borrow());
// Dieser Code würde einen Panic verursachen, da zwei Schreibzugriffe
// let mut_refa = cell.borrow_mut();
// let mut_refb = cell.borrow_mut();
// a.push_str(" -> A war hier");
// b.push_str(" -> B war hier");
}

RefCell

Um zu entscheiden, welchen Smart Pointer man benötigt, muss man zwei Fragen beantworten: “Wie viele Besitzer gibt es?” und “Wann wird das Ownership geprüft?”.

  1. Ownership (Besitz)
    • Rc / Arc: Ermöglicht mehrere Besitzer für dieselben Daten
    • Box und RefCell: Erlauben nur einen einzigen Besitzer
  2. Borrow Checking (Wann wird geprüft?) Hier liegt der entscheidende Unterschied zwischen Box und RefCell:
    • Box (Statisch): Der Compiler prüft die Borrowing-Regeln zur Compile-Zeit. Wenn du einen Fehler machst, kompiliert das Programm nicht.
    • RefCell (Dynamisch): Die Regeln werden erst zur Laufzeit geprüft. Wenn du einen Fehler machst (z.B. zwei mutable Borrows gleichzeitig), stürzt das Programm ab (Panic).

Quiz zum Thema

Blog Beitrag

https://www.youtube.com/watch?v=8O0Nt9qY_vo

https://www.youtube.com/watch?v=xNrglKGi-7o&list=PLzcWTPTvhHtVWMJF_27EVB0cef7KUfsB3&index=59

https://www.youtube.com/watch?v=HwupNf9iCJk

https://www.youtube.com/watch?v=rzYS7dwGrhA