Auf Nummer sicher: Sicheres Programmieren mit Rust

Seite 3: Shared Mutable State und Reference Counting

Inhaltsverzeichnis

Das strikte Ownership-Konzept von Rust ist zwar äußerst sicher und gleichzeitig sehr flexibel, deckt jedoch einige Anwendungsfälle nicht ab. So lassen sich bestimmte Datenstrukturen nicht oder nur schwer mit einem so strikten Modell umsetzen. Hierzu gehören unter anderem doppelt verkettete Listen, bei denen ein Knoten sowohl von einem Vorgänger- als auch von einem Nachfolger-Knoten referenziert wird. Die Referenzen gilt es zusätzlich als veränderbare Referenzen zu definieren, damit eine Änderung des referenzierten Wertes möglich wird.

Um diesem Umstand Rechnung zu tragen, bietet Rust Möglichkeiten, auch solche Situationen sicher zu handhaben. Listing 9 veranschaulicht das.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let cr = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = cr.lock().unwrap();
            *num += 1;
        });

        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

Listing 9: Mutex-API und Atomic Reference Counting in einem nebenläufigen Programm

Bei dem Listing handelt es sich im Wesentlichen um ein Programm, das nebenläufig bis 10 zählt. Für jedes Inkrement startet es einen eigenen Thread. Alle Threads besitzen eine Referenz auf einen gemeinsamen Zählerwert, der jedoch sicher inkrementiert wird.

Hierbei kommen zwei Techniken zum Einsatz, die gleichzeitigen Zugriff über eine veränderbare Referenz (shared mutable state) sowie gleichzeitigen Zugriff mehrerer Besitzer über eine unveränderliche Referenz (reference counting) mithilfe einer sicheren API ermöglichen.

Die in Listing 9 verwendete Mutex-API (std::sync::Mutex) ermöglicht es, eine Referenz auf einen Wert so zu kapseln, dass mehrere Konsumenten der Referenz (beispielsweise Threads) den Wert auf sichere Weise modifizieren können. Zu diesem Zweck wird der veränderbare Wert (der Startwert 0 des Zählers) an den Konstruktor eines Mutex-Objektes übergeben. Der Konstruktor liefert eine Referenz auf das Mutex-Objekt zurück. Den Zugriff auf den Wert der Referenz regelt dabei die lock-Methode des Mutex-Objektes. Sie liefert die eigentliche, veränderbare Referenz auf den Wert an einen Konsumenten und sperrt die Referenz gleichzeitig gegen weitere Zugriffe, bis die Sperre explizit oder implizit aufgehoben wird. Hebt man die Sperre auf, werden weitere potenzielle Konsumenten darüber benachrichtigt, dass nun wieder ein Zugriff auf den Wert möglich ist. Ab diesem Zeitpunkt ist es wieder möglich, die Sperre an einen weiteren Konsumenten neu zu vergeben.

Manch einer mag sich nun fragen, wie mehrere Konsumenten wie die Threads im obigen Beispiel gleichzeitig eine eigene Referenz auf ein Objekt halten können. Grundsätzlich schreibt das Ownership-Konzept ja vor, dass eine Referenz immer einen eindeutigen Besitzer haben muss, wie im vorigen Abschnitt besprochen.

Das ist zwar korrekt, jedoch gibt es auch hierfür eine sichere API, die mittels Reference Counting solche Konstrukte ermöglicht. Die Reference-Counting-API gibt es in zwei Ausprägungen: nicht thread-safe (std::rc::Rc), aber auch thread-safe in Form des Atomic Reference Counting (std::sync::Arc). Beide APIs leisten jedoch ähnliches, weshalb es an dieser Stelle genügt, sich auf das Atomic Reference Counting (Arc) zu beschränken.

Listing 9 verpackt die Referenz auf das Mutex-Objekt in einem Arc-Objekt. Die Referenz lässt sich dann beliebig oft klonen, unter anderem für jeden neu gestarteten Thread. Dabei lässt sich die Anzahl der Klone fest vorgeben, denn Zugriffe über eine solche Referenz sind nur lesend erlaubt. Das automatische Speichermanagement des Rust-Compilers verwaltet auch die Arc-Klone. Sobald der letzte Klon am Ende seines Lebenszyklus angekommen und zerstört worden ist, zerstört das Speichermanagement auch das Objekt, auf das die Arc-Referenz verweist. Im Beispiel betrifft das das Mutex-Objekt mit dem darin enthaltenen Zählerwert.

Die dargestellten Konzepte und APIs erlauben es dem Rust-Compiler, auch in so komplexen Fällen wie nebenläufigen Programmen mit gemeinsamem Zugriff auf geteilte Ressourcen den Lebenszyklus von Werten im Speicher nachzuvollziehen. Dadurch wird eine sichere Speicherverwaltung möglich, die Freiheit von Speicherfehlern und Race Conditions auch in komplexen Szenarien weitgehend garantiert.

Aufmerksamen Lesern wird das folgende Dilemma nicht entgangen sein: Wie lassen sich Konstrukte wie das Atomic Reference Counting oder die Mutex-API, die einen shared mutable state umsetzen, in Rust selbst implementieren, wo doch das strikte Ownership-Konzept genau das eigentlich verhindern soll?

Die Antwort lautet: Die Konstrukte lassen sich in Rust nicht implementieren, jedenfalls nicht in dem sicheren Teil von Rust, den der Artikel bislang behandelt hat. Mit "Unsafe Rust" gibt es jedoch eine Möglichkeit, die strikten Regeln von "Safe Rust" so weit zu lockern, dass auch unsichere Konstrukte verwendet werden können. Dies bedeutet jedoch nicht, dass die Sicherheitsmechanismen von "Safe Rust" für diesen unsicheren Code außer Kraft treten. Es wird lediglich die Verwendung unsicherer Konstrukte ermöglicht, auf welche die Sicherheitsmechanismen nicht angewendet werden können, wie Listing 10 zeigt.

fn main() {
    let mut num = 5;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}

Listing 10: Explizite Markierung eines Code-Blocks als "unsafe" beim Zugriff auf raw-pointer

Der Code erzeugt zwei sogenannte raw pointer, also Referenzen auf einen Wert, die nicht durch das automatische Speichermanagement des Compilers abgedeckt sind. Die Referenz r1 ist dabei eine shared reference, die Referenz r2 eine mutable reference. Beide Referenzen zeigen auf denselben Wert, was der Borrow Checker in Safe Rust verhindern würde. Das Erzeugen solcher raw pointer ist in Safe Rust durchaus erlaubt, nicht jedoch der Zugriff auf den derart referenzierten Wert. Der Zugriff darf nur innerhalb eines unsafe-Blocks stattfinden, durch den die Verwendung unsicherer Konstrukte gekennzeichnet ist.

Die Idee hinter "Unsafe Rust" ist es, unsicheren Code durch unsafe-Blöcke explizit kenntlich zu machen und ihre Verwendung auf ein Minimum zu reduzieren. Sollte in einem Programm, das unsicheres Rust verwendet, ein Speicherfehler auftreten, so weiß die Entwicklerin beziehungsweise der Entwickler sofort, an welchen Stellen man den Fehler suchen sollte. Weiterhin gilt die Empfehlung, unsicheren Code hinter einer sicheren API zu verstecken, um ihn zu isolieren.

Unsicheres Rust kann auch dazu verwendet werden, um externen Code wie Funktionen aus Bibliotheken aufzurufen, die in C oder C++ implementiert wurden. Auch solche Funktionsaufrufe sind in einem unsafe-Block durchzuführen, da es sich um potenziell unsichere Aufrufe handelt.

extern "C" {
    fn pow(input: f64, exp: f64) -> f64;
}

fn main() {
    unsafe {
        println!("2^3 is: {}", pow(2.0, 3.0));
    }
}

Listing 11: Der Zugriff auf externe C-Biblitheksfunktionen muss ebenfalls als "unsafe" gekennzeichnet werden

In dem Beispiel wird die Potenzfunktion aus der C-Standardbibliothek aufgerufen. Der externe C-Block definiert dabei die Signatur der externen Funktion sowie die Art, wie die Funktion auf Ebene des Assemblers aufzurufen ist. Da es sich um den Aufruf einer unsicheren Funktion handelt, unterliegt das Speichermanagement nicht der Kontrolle des Rust-Compilers, und der Aufruf muss in einem eigenen unsafe-Block erfolgen.