Threads

Threads können die Effizienz von Programmen erhöhen. Ihr Einsatz erhöht definitiv die Komplexität des Programms. Das Erstellen und Starten von Threads kostet definitiv auch immer CPU-Ressourcen. Das impliziert, dass der Einsatz von Threads immer die erhöhte Komplexität rechtfertigen sollte. Die Reihenfolge, in der Threads abgearbeitet werden obliegt dem Betriebssystem. Insbesondere können folgende Probleme beim Einsatz von Threads auftreten:

  • Es kann zu Race Conditions kommen. Dabei findet der Zugriff auf Daten, die von mehreren Threads benötigt werden, auf inkonsistente Art und Weise statt. Beispielsweise könnte ein Thread an einer Speicheradresse etwas schreiben, während ein anderer etwas liest.
  • Mit Deadlocks bezeichnet man Situationen in denen beispielsweise zwei Threads auf gegenseitige Beendigung warten, bevor sie weiterlaufen können.
  • Es kann zu schwer nachvollziehbaren Bugs kommen, die nur unter bestimmten Situationen z.B. abhängig von der Reihenfolge der ausgeführten Threads auftreten.

Rust verfolgt das Ziel das Aufkommen derartiger Probleme zu minimieren. Trotzdem ist auch in Rust die Programmierung mit mehreren Threads komplexer und fehleranfälliger.

Threads erstellen

In Rust kann ein Thread mit der Funktion std::thread::spawn erstellt werden. Als Argument bekommt spawn eine Closure ohne Parameter, die ausgeführt wird.

use std::thread;
use std::time::Duration;

fn main() {
    // Closure, die vom Thread ausgeführt wird
    let print_i = || {
        for i in 0..10 {
            println!("{i} spawned thread");
            thread::sleep(Duration::from_micros(20));
        }
    };

    // Separater Thread wird asynchron gestartet
    thread::spawn(print_i);

    // Hauptthread geht weiter
    for i in 0..4 {
        println!("{i} main thread");
        thread::sleep(Duration::from_micros(20));
    }
}

Sobald der Hauptthread fertig ist, werden alle separat gestarteten Threads beendet. Die Ausgabe des obigen Schnipsels ist nicht deterministisch sondern von abhängig von den Launen des Betriebssystems, könnte aber folgendermaßen aussehen.

0 main thread
0 spawned thread
1 main thread
1 spawned thread
2 main thread
2 spawned thread
3 main thread
3 spawned thread
4

Die letzte Zeile zeigt nur eine 4. Der separate Thread wurde also mitten in der Ausgabe beendet.

Auf Threads warten

Die Funktion thread::spawn gibt eine Instanz des Typs JoinHandle zurück. Ein JoinHandle hat eine Methode join, die auf das Beenden des gestartetn Threads wartet.

use std::thread;
use std::time::Duration;

fn main() {
    // Closure, die vom Thread ausgeführt wird
    let print_i = || {
        for i in 0..10 {
            println!("{i} spawned thread");
            thread::sleep(Duration::from_micros(20));
        }
        73
    };

    // Separater Thread wird asynchron gestartet
    let handle = thread::spawn(print_i);

    // Hauptthread geht weiter
    for i in 0..4 {
        println!("{i} main thread");
        thread::sleep(Duration::from_micros(20));
    }
    match handle.join() {
        Ok(x) => println!("the thread returned {x}"),
        Err(e) => println!("thread panicked due to {e:?}"),
    }
}

Die Methode join wartet nicht nur auf Beendigung des Threads, sie auch gibt den Rückgabewert des Closures im Thread zurück oder einen Fehler, falls der separate Thread eine Panikattacke erlitten hat. Dementsprechend kann die nicht-deterministische Ausgabe folgendermaßen aussehen.

0 main thread
0 spawned thread
1 spawned thread
1 main thread
2 main thread
2 spawned thread
3 main thread
3 spawned thread
4 spawned thread
5 spawned thread
6 spawned thread
7 spawned thread
8 spawned thread
9 spawned thread
the thread returned 73

Die letzte Zeile ist jedoch deterministisch, falls das Betriebssystem das Starten des Threads zugelassen hat.

Ownership von erfassten Variablen in Threads

Wenn unser separater Threads eine Closure ausführen soll, die Variablen aus dem umgebenden Geltungsbereich erfasst, ist es am einfachsten, Ownership der Closure über die Variable zu erfordern. Closures leiten von der Verwendung der Variablen ab, ob die Variablen aus der Umgebung per Referenz erfasst werden, oder ob die Closure Ownership übernimmt. Um die Verwendung per Referenz zu verhindern, gibt es das Schlüsselwort move, das Ownership der Closure erzwingt.

Quiz

Wovon hängt ab, ob per move eine Variable in eine Closure verschoben oder kopiert wird?

Im folgenden Schnipsel sehen wir, wie eine Closure eine Variable per Referenz erfassen möchte, was nicht funktioniert.

// kompiliert nicht
use std::thread;
fn main() {
    let n = 5;
    let print_n = || {
        println!("{n}");
    };
    thread::spawn(print_n);
}

Der Borrow-Checker erlaubt zur Kompilierzeit nicht, dass eine Referenz in einem separaten Thread verwendet wird, da er nicht wissen kann, wie lange der Thread lebt. Um das Problem zu beheben, erzwingen durch Verwendung von move wir eine Kopie von n.

use std::thread;
fn main() {
    let n = 5;
    let print_n = move || {
        println!("{n}");
    };
    thread::spawn(print_n).join();
}

Eine weitere in Rust recht neue Methode ist die Verwendung von thread::scope. Die Funktion scope erhält eine Closure als Argument. Diese Closure bekommt als Argument wiederum eine Scope-Instanz. Der Typ Scope hat eine spawn-Methode, die verwendet werden kann, um Threads zu starten. Am Ende des Geltungsbereichs der Scope-Instanz wird durch die entsprechende Drop-Implementierung auf die Beendigung aller durch die Scope-Instanz gestarteten Threads gewartet. Dadurch kann der Borrow-Checker sicher sein, dass alle Threads und die entsprechenden Referenzen nicht mehr vorhanden sind.

use std::thread;
fn main() {
    let n = 5;
    thread::scope(|s|{
        let print_n = || {
            println!("{n}");
        };
        s.spawn(print_n);
    });
}