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,