Undefiniertes Verhalten: Rust kommt zur Hilfe 26. Dezember 2022 | 13 minute Lesen

Undefiniertes Verhalten: Rust kommt zur Hilfe

Sind Sie schon einmal auf einen seltsamen Fehler in Ihrem Code gestoßen, den Sie einfach nicht beheben konnten? Das ist frustrierend, nicht wahr? Noch frustrierender ist es, wenn die Grundursache des Problems etwas ist von dem Sie nicht einmal wussten, dass es ein potenzielles Problem sein könnte. An dieser Stelle kommt das Konzept des undefiniertes Verhalten (Undefined Behavior) ins Spiel.

In der Computerprogrammierung bezieht sich undefiniertes Verhalten auf das Ergebnis einer Operation oder eines Ausdrucks das nicht in der Spezifikation der Sprache angegeben ist. Mit anderen Worten: Die Sprache legt nicht fest was passieren soll, wenn bestimmte Bedingungen erfüllt sind, und überlässt es dem Compiler oder der Laufzeitumgebung, zu entscheiden zu entscheiden, wie damit umzugehen ist. Dies kann zu allen möglichen seltsamen und unerwarteten Problemen in Ihrem Code führen.

undefiniertes Verhalten kann sich sehr negativ auf ein Unternehmen auswirken, da es unerwartete Fehlern und Bugs verursacht, die schwer zu finden und zu beheben sind. Diese Probleme können zu Zeitverlust, Geldverlust, und sogar zum Verlust von Kunden führen, wenn sie ein fehlerhaftes Produkt zur Folge haben. Es ist wichtig, dass Programmierer undefiniertes Verhalten zu erkennen und zu vermeiden, um stabilen, zuverlässigen Code zu produzieren.

Hier kommt Rust ins Spiel. Rust ist eine Programmiersprache, die mit dem Ziel entwickelt wurde, undefiniertes Verhalten von Anfang an zu vermeiden. In Rust sind der Compiler und die Laufzeitumgebung darauf ausgelegt, undefiniertes Verhalten abzufangen und zu verhindern, um sicherzustellen, dass Ihr Code stets zuverlässig und stabil läuft. In diesem Blogbeitrag werfe ich einen genaueren Blick auf undefiniertes Verhalten in drei beliebten Programmiersprachen (C, C++ und Java) und wie Rust helfen kann, es zu verhindern.

Undefiniertes Verhalten in C

C ist eine beliebte Programmiersprache, die es schon seit Jahrzehnten gibt. Sie ist bekannt dafür, schnell und effizient zu sein, aber sie hat auch den Ruf, etwas unversöhnlich zu sein, wenn es um undefiniertes Verhalten geht. Werfen wir einen Blick auf einige Beispiele für undefiniertes Verhalten in C und wie Rust dabei helfen kann, es zu verhindern.

Überlauf einer Ganzzahl mit Vorzeichen

Eine häufige Art von undefiniertem Verhalten in C ist der Überlauf von Ganzzahlen mit Vorzeichen. Dieser tritt auf, wenn das Ergebnis einer arithmetischen Operation den Maximalwert überschreitet, der in einem Datentyp mit Vorzeichen gespeichert werden kann. In C ist das Verhalten bei einem Überlauf von Ganzzahlen mit Vorzeichen nicht definiert, was bedeutet, dass der Compiler frei ist, mit dem Ergebnis zu tun, was er will. Dies kann zu allen möglichen seltsamen und unerwarteten Problemen in Ihrem Code führen.

Hier ist ein Beispiel in C:

#include <stdio.h>

int main() {
  int x = 2147483647;  // maximum value for a signed int
  x = x + 1;           // undefined behavior
  printf("%d", x);
  return 0;
}

In diesem Beispiel beginnen wir mit dem Maximalwert für eine vorzeichenbehaftete Zahl und versuchen, 1 dazu zu addieren. Dies sollte zu einem vorzeichenbehafteten Ganzzahl-Überlauf führen, aber das Verhalten der Operation ist undefiniert. Es ist also ungewiss, was passieren wird. Der Compiler könnte den Wert einfach auf den Minimalwert für eine vorzeichenbehaftete int, oder er könnte etwas völlig anderes tun. Dies kann sehr verwirrend und frustrierend für Programmierer sein, die versuchen, ihren Code zu debuggen.

Schauen wir uns nun an, wie Rust mit dieser Situation umgeht:

fn main() {
  let x = 2147483647;            // maximum value for a signed int
  let y = x.overflowing_add(1);  // tuple of (result, overflow)
  println!("{:?}", y);           // (2147483648, true)
}

In Rust haben wir eine Methode namens overflowing_add(), mit der wir zwei ganze Zahlen addieren und prüfen können ob das Ergebnis übergelaufen ist. Diese Methode gibt ein Tupel zurück, das das Ergebnis der Operation und einen booleschen Wert, der angibt, ob die Operation zu einem Überlauf geführt hat. Auf diese Weise können wir mit dem Überlauf auf kontrollierte und vorhersehbare Weise behandeln, anstatt sich auf undefiniertes Verhalten zu verlassen.

Rust im Entwicklungsmodus wird Ganzzahl-Überläufe verhindern und in solchen Fällen eine Panic werfen. Dieses Verhalten kann mit einem Cargo-Flag überschrieben werden. Im Release-Modus werden diese Überlaufprüfungen entfernt und standardmäßig zu Wrapping-Operationen. Lesen Sie mehr darüber in dem Rust Book.

Out of bound access

Eine weitere häufige Art von undefiniertem Verhalten in C ist der Out-of-Bound-Zugriff, der auftritt, wenn wir versuchen auf ein Element eines Arrays zuzugreifen, das außerhalb der Grenzen des Arrays liegt. In C ist dieses Verhalten ebenfalls undefiniert, was bedeutet, dass der Compiler mit dem Ergebnis machen kann, was er will. Dies kann zu allen möglichen seltsamen und unerwarteten Problemen in Ihrem Code führen.

Hier ist ein Beispiel in C:

#include <stdio.h>

int main() {
  int arr[5] = {1, 2, 3, 4, 5};
  int x = arr[5];   // undefined behavior
  printf("%d", x);
  return 0;
}

In diesem Beispiel wird versucht, auf das Element bei Index 5 eines Arrays zuzugreifen, das nur 5 Elemente hat. Dies sollte zu einem Out-of-Bound-Zugriff führen, aber das Verhalten der Operation ist undefiniert.

Schauen wir uns nun an, wie Rust mit dieser Situation umgeht:

fn main() {
  let arr = [1, 2, 3, 4, 5];
  let x = match arr.get(5) {  // returns None if out of bounds
    Some(x) => x,
    None => {
      println!("Out of bounds!");   // out of bounds error printed
      return;
    }
  };
  println!("{}", x);
}

In Rust gibt es eine Methode namens “get()”, mit der wir sicher auf Elemente eines Arrays zugreifen können. Diese Methode gibt einen Typ Option zurück, der entweder das angeforderte Element oder None enthält, wenn der Index außerhalb der Grenzen liegt. Auf diese Weise können wir den Zugriff außerhalb der Grenzen kontrolliert und vorhersehbar behandeln Weise umgehen, anstatt sich auf undefiniertes Verhalten zu verlassen.

Wie wir sehen können, bietet Rust eine Reihe von Möglichkeiten, undefiniertes Verhalten in C zu verhindern, was es zu einer sichereren und zuverlässigere Wahl für die Programmierung macht.

Undefiniertes Verhalten in C++

C++ ist eine weitere beliebte Programmiersprache, die in der Industrie weit verbreitet ist. Wie C ist sie bekannt dafür, schnell und effizient zu sein, aber sie hat auch den Ruf, anfällig für undefiniertes Verhalten zu sein. Werfen wir einen Blick auf ein Beispiel für undefiniertes Verhalten in C++ und wie Rust helfen kann, dies zu verhindern.

Die Verwendung eines Objekts, nachdem es zerstört wurde

Eine häufige Art von undefiniertem Verhalten in C++ ist die Verwendung eines Objekts, nachdem es zerstört wurde. Dies kann passieren, wenn ein Objekt gelöscht wird oder aus dem Geltungsbereich verschwindet, es aber noch verwendet oder darauf zugegriffen wird. In C++ ist das Verhalten dieses Vorgangs undefiniert, was bedeutet dem Compiler steht es frei, mit dem Ergebnis zu tun, was er will. Dies kann zu allen möglichen seltsamen und unerwarteten Problemen in Ihrem Code führen.

Hier ist ein Beispiel in C++:

#include <iostream>

class MyClass {
 public:
  MyClass() {}
  ~MyClass() {}
  some_method() { std::cout << "something" << std::endl; }
};

int main() {
  MyClass* obj = new MyClass();
  delete obj;
  obj->some_method();  // undefined behavior
  return 0;
}

In diesem Beispiel erstellen wir mit dem Schlüsselwort new eine neue Instanz von MyClass und löschen sie anschließend mit dem Schlüsselwort delete. Allerdings versuchen wir dann, eine Methode für das Objekt aufzurufen, nachdem es zerstört wurde, was zu undefiniertem Verhalten führen wird.

Schauen wir uns nun an, wie Rust mit dieser Situation umgeht:

struct MyStruct {
  data: i32,
}

fn main() {
  let obj = MyStruct { data: 42 };
  let p = &obj;
  drop(obj); // compile-time error: Can't borrow obj mutably, since it's still used.
  println!("{}", p.data);
}

In Rust müssen wir uns nicht um die Verwendung eines Objekts kümmern, nachdem es zerstört wurde, da der Rust-Compiler dieses Problem bereits bei der Kompilierung erkennt und verhindert, dass es auftritt. In diesem Beispiel, erstellen wir eine neue Instanz von MyStruct und erstellen dann eine Refeerenz auf sie. Dann lassen wir das Objekt fallen, was es zerstören und die Refeerenz ungültig macht. Wenn wir jedoch versuchen, auf das Datenfeld der Referenz zuzugreifen, gibt der Rust-Compiler einen Kompilierfehler aus, der uns mitteilt, dass wir dass wir versuchen, ein Objekt zu verwenden, nachdem es bereits zerstört wurde. Um genauer zu sein, sagt uns der Compiler dass wir das obj nicht fallen lassen können, da es später verwendet wird. Dies stellt sicher, dass unser Code immer sicher und zuverlässig läuft, ohne das Risiko eines undefinierten Verhaltens.

Wie wir sehen können, bietet Rust eine Reihe von Möglichkeiten, undefiniertes Verhalten welches in C++ auftretten kann, verhindert.

Undefiniertes Verhalten in Java

Java ist eine beliebte Programmiersprache, die für ihre Einfachheit, Portabilität und Objektorientierung bekannt ist. Sie wird in einer Vielzahl von Anwendungen eingesetzt, von der Webentwicklung bis zur Entwicklung von Android-Apps.

Ein Aspekt von Java, der für Programmierer eine Herausforderung darstellen kann, ist die Handhabung des gleichzeitigen Zugriffs auf geteilte Daten durch mehrere Threads. In Java gilt der unsynchronisierte Lese- und Schreibzugriff auf geteilte Daten als undefiniertes Verhalten. Dies bedeutet, dass der Java-Compiler die Korrektheit des Programms nicht garantieren kann, wenn mehrere Threads gleichzeitig auf geteilte Daten zugreifen.

Dies kann für Programmierer ein Problem darstellen, da es schwierig sein kann festzustellen, ob eine Klasse in Java Thread-sicher ist oder nicht. Selbst wenn eine Klasse Thread-sicher zu sein scheint, kann sie dennoch ein unerwartetes Verhalten zeigen wenn sie in einer Multithreading- Umgebung verwendet wird. Dies kann zu schwer zu behebenden Problemen und potenziellen Sicherheitsschwachstellen führen.

Java-Beispiel

Um das Problem des undefinierten Verhaltens in Java bei der Verwendung mehrerer Threads zu veranschaulichen, sei folgendes Beispiel gegeben:

public class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) {
        Counter counter = new Counter();
        Runnable r = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(r);
        Thread t2 = new Thread(r);

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(counter.getCount());  // expected output: 2000
    }
}

In diesem Beispiel haben wir eine Klasse Counter, die eine einfache Inkrement-Methode und eine Getter-Methode für die Variable count hat. In der Hauptmethode erstellen wir zwei Threads, die jeweils eine Schleife durchlaufen, welche den Zähler 1000 Mal erhöht. Anschließend starten wir die Threads und warten, bis sie beendet sind, bevor wir den endgültigen Zählerstand auslesen.

Anhand des Codes würden wir erwarten, dass der endgültige Zählerstand 2000 beträgt, da jeder Thread den Zähler 1000 Mal erhöht. Da die Inkrementierungsoperation jedoch nicht synchronisiert ist, ist es möglich, dass die Threads ihre Inkremente so verschachteln, dass der endgültige Zählerstand kleiner ist als 2000. Dies ist ein Beispiel für undefiniertes Verhalten in Java.

Trotzdem wird der Java-Compiler den Code nicht als fehlerhaft zurückweisen. Es ist Sache des Programmierers sicherzustellen, dass der Code korrekt und Thread-sicher ist.

Rusts Superheldenkraft zur Kompilierzeit

Im Gegensatz zu Java hat Rust einen starken Fokus auf zur Komplierzeit verifizierter Threadsicherheit. Um dies zu gewährleisten zu gewährleisten, hat Rust, neben dem Borrow Checker, zwei Traits namens Send und Sync.

Der Send Trait zeigt an, dass ein Typ sicher ist, um über Threads hinweg gesendet zu werden. Das bedeutet, dass wenn ein Typ Send implementiert, kann er als Parameter an einen Thread übergeben oder in einem Arc (atomic reference counted pointer) gespeichert werden.

Der Trait Sync zeigt an, dass ein Typ sicher ist und von mehreren Threads gemeinsam genutzt werden kann. Das bedeutet, dass wenn ein Typ Sync implementiert, kann er in einem Mutex oder RwLock (read-write lock) gespeichert werden gespeichert werden und sicher von Threads gleichzeitig benutzt werden. Außerdem sind Atomic* Typen sync.

Durch die Verwendung dieser Traits kann Rust zur Kompilierzeit garantieren, dass bestimmte Typen thread-sicher sind. Dies hilft Programmierern, die Probleme mit undefiniertem Verhalten zu vermeiden, die in Java bei der Verwendung mehrerer Threads auftreten können.

Um zu demonstrieren, wie Rust’s Send und Sync Traits verwendet werden können, um dieses Problem des undefinierten Verhalten zu lösen, das wir im Java-Beispiel gesehen haben, betrachten wir den folgenden Rust-Code:

use std::cell::Cell;
use std::sync::Arc;
use std::thread;

struct Counter {
    count: Cell<u32>,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: Cell::new(0) }
    }

    fn increment(&self) {
        let old = self.count.get();
        self.count.set(old + 1);
    }

    fn get_count(&self) -> u32 {
        self.count.get()
    }
}

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

    for _ in 0..1000 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let counter = counter;
            counter.increment();
        });
        handles.push(handle);
    }

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

    println!("{}", counter.get_count());  // expected output: 1000
}

In diesem Rust-Code haben wir eine Counter-Struktur mit einer Inkrement-Methode und einer Getter-Methode für die Zählervariable. In der Hauptfunktion erstellen wir einen Vektor von 1000 Threads, von denen jeder die Zählung einmal erhöht.

Anders als im Java-Beispiel lässt sich der Rust-Code jedoch nicht kompilieren. Das liegt daran, dass die Counter-Struktur die Traits Send oder Sync nicht implementiert (da es den threadunsicheren Typ Cell zum Speichern des Zählerwert benutzt), was für die sichere gemeinsame Nutzung von Typen durch Threads erforderlich ist.

Behebung des Problems

Wie können wir also den Zähler Send und Sync machen, so dass es für zwei Threads möglich ist den Zähler gleichzeitig zu erhöhen.

Um dieses Problem zu lösen, können wir die atomaren Typen von Rust verwenden, um sicherzustellen, dass die Inkrement-Operation atomar ist. Ein atomarer Typ ist ein Typ, der einen atomaren Zugriff auf seine Daten bietet, was bedeutet, dass er Daten garantiert in einer einzigen, atomaren, Operation aktualisiert werden und von mehreren Threads gleichzeitig zugegriffen werden kann.

Hier ein Beispiel dafür, wie wir die “Counter”-Struktur so ändern können, dass sie einen atomaren Typ verwendet:

use std::thread;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};

struct Counter {
    count: AtomicU32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: AtomicU32::new(0) }
    }

    fn increment(&self) {
        self.count.fetch_add(1, Ordering::SeqCst);
    }

    fn get_count(&self) -> u32 {
        self.count.load(Ordering::SeqCst)
    }
}

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

    for _ in 0..1000 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let counter = counter;
            counter.increment();
        });
        handles.push(handle);
    }

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

    println!("{}", counter.get_count());  // expected output: 1000
}

In diesem Beispiel verwenden wir den Typ “AtomicU32”, um den Zähler zu speichern. Die Methode fetch_add() wird verwendet verwendet, um den Zählerstand atomar zu erhöhen, und die Load-Methode wird verwendet, um den Zählerstand atomar zu lesen. Der Parameter “Ordering” gibt die Speicherreihenfolge der atomaren Operationen an.

Durch die Verwendung atomarer Typen wird sichergestellt, dass die “Counter”-Struktur thread-sicher ist und dieses Problem des undefinierten Verhaltens, welches wir in Java gesehen haben, zur Kompilierzeit verhindert wird.

Zusammenfassend lässt sich sagen, dass Rust’s Send und Sync Traits und verschiedene std::sync Typen einen robusten und sicheren Weg bieten, um den gleichzeitigen Zugriff auf gemeinsame Daten in Multithreading-Programmen zu handhaben. Diese Eigenschaften helfen Programmierern, diese Probleme des undefiniertem Verhaltens zur Kompilierungszeit vermeiden, die in Sprachen wie Java bei der Verwendung mehrerer Threads auftreten können.

Verhindern von undefiniertem Verhalten mit Rust

In diesem Blogbeitrag haben wir gesehen, wie undefiniertes Verhalten in Programmiersprachen wie C, C++ und Java ein großes Problem sein kann. Undefiniertes Verhalten kann zu allen möglichen seltsamen und unerwarteten Problemen in Ihrem Code führen, was die Fehlersuche und Wartung erschwert.

Wir haben aber auch gesehen, wie Rust helfen kann, undefiniertes Verhalten in diesen Sprachen zu verhindern. Rust bietet eine Reihe von Funktionen, die dafür sorgen, dass Ihr Code immer sicher und zuverlässig ist:

  • Automatische Überprüfung der Grenzen von Arrays und Vektoren
  • Kontrollierte und vorhersehbare Behandlung von Out-of-Bounds-Zugriffen
  • Sichere Behandlung von Ganzzahl-Überläufen
  • Automatische Erkennung von “Use after free”-Fehlern zur Kompilierung
  • Kompilierzeitgeprüfte Synchronisierung des Zugriff von Threads auf geteilten Daten

Durch die Verwendung dieser und anderer Funktionen kann Rust Ihnen helfen, Code zu schreiben, der sicher, zuverlässig und frei von von undefiniertem Verhalten ist. Dies macht Rust zu einer großartigen Wahl für die Programmierung, besonders für geschäftskritische Projekte.

Wenn Sie es also leid sind, sich mit undefiniertem Verhalten herumzuschlagen, und Code schreiben wollen, der sicher, zuverlässig und einfach zu warten ist, sollten Sie Rust ausprobieren. Vielleicht stellen Sie fest, dass es die perfekte Programmiersprache Sprache für Ihre Bedürfnisse ist.

Nils Hasenbanck

Nils Hasenbanck

Nils Hasenbanck ist der Gründer der Tsukisoft GmbH und ein Senior Developer. Seine Leidenschaft ist das Bauen von technisch eleganten, …