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); }); }