Multithreading ist ein essenzieller Bestandteil moderner Softwareentwicklung, um Programme performant und reaktionsfähig zu gestalten. In der Programmiersprache Rust, die für ihre Sicherheit und Effizienz bekannt ist, stellt die Verwaltung von Threads eine besondere Herausforderung dar, insbesondere wenn es darum geht, Threads unmittelbar nach ihrem Abschluss zu verbinden und deren Ergebnisse zu verarbeiten. Anders als manche andere Programmiersprachen bietet die Standardbibliothek von Rust keine direkte Methode, Threads ohne Wartezeit nacheinander zu joinen. Dieses Verhalten kann jedoch in vielen Anwendungen einen großen Unterschied machen, wenn es darum geht, schneller auf Task-Abschlüsse zu reagieren und Ressourcen effizient zu verwalten. In Rust begann man bisher oft mit dem Erstellen von JoinHandles für jeden gestarteten Thread, die man dann am Ende in einer Schleife nacheinander verbindet.
Dies führte dazu, dass der Entwickler nicht wusste, wann genau welcher Thread beendet wurde, was insbesondere in Szenarien mit unterschiedlich langen Aufgaben zu ineffizienter Wartezeit führen kann. Die JoinHandle::join-Methode blockiert nämlich, bis der entsprechende Thread beendet ist. Dadurch kann man nicht einfach mehrere Threads gleichzeitig beobachten, um ihre jeweiligen Ergebnisse sofort bei Fertigstellung zu verarbeiten. Ein denkbarer Ausweg war das wiederholte Überprüfen mit JoinHandle::is_finished in Kombination mit einem kurzen Schlafintervall. Dabei werden alle Handles kontinuierlich auf Fertigstellung abgefragt, und sobald einer fertig ist, wird er gejoint.
Obwohl diese Methode funktional ist, leidet sie unter hohem Ressourcenverbrauch und suboptimaler Systemauslastung, da das Polling eine gewisse CPU-Last erzeugt und keine wirklich elegante Lösung darstellt. Eine Inspiration bietet hierbei Python mit seinem ThreadPoolExecutor und Futures, wo man mittels der Funktion as_completed eine Liste von Futures beobachten kann, um sofort bei Fertigstellung eines Threads dessen Ergebnis zu verarbeiten. Das vergleichbare Feature fehlt jedoch in Rusts Standardbibliothek, sodass Entwickler auf alternative Wege zurückgreifen müssen, die teilweise komplexer sind. Eine innovative und wirkungsvolle Lösung besteht darin, sogenannte „self-shipping threads“ zu implementieren. Das bedeutet, dass jeder Thread sich selbst meldet, sobald er seinen Job beendet hat und seinen eigenen JoinHandle zusammen mit dem Ergebnis an den Hauptthread zurücksendet.
Die Kommunikation erfolgt über Kanäle (mpsc), wodurch der Hauptthread nicht ständig selbst nachfragen muss, sondern aktiv informiert wird, sobald ein Thread fertig ist. Dabei nutzt man Rusts Scoped Threads API um sicherzustellen, dass Thread-Lebenszeiten korrekt eingehalten werden können. Bei der Implementierung wird ein Sender-Kanal vom Hauptthread erstellt und an jeden erzeugten Thread übergeben. Im Thread wird der JoinHandle über eine interne Kanalübertragung nochmals verschickt, sodass gewährleistet ist, dass der Hauptthread den Handle genau dann erhält, wenn der Thread abgeschlossen wurde. Dies ermöglicht dem Hauptthread, die einzelnen Beendigungen in nahezu Echtzeit zu verarbeiten und sofort Resultate zu erhalten.
Eine Herausforderung ergibt sich bei panikenden Threads. Wird in einem Thread ein Panic ausgelöst, so wird bezogen auf diese self-shipping-Methode ohne Absicherung der JoinHandle möglicherweise kein Signal zum Hauptthread geschickt, was zu Deadlocks oder hängenden Programmen führen kann. Um dieses Problem zu umgehen, wird in der Praxis häufig eine Drop-Implementierung verwendet, die garantiert, dass auch bei einem Panic der JoinHandle gesendet wird – und zwar durch ein kleines Hilfsobjekt, das beim Verlassen des Threads immer ausgeführt wird. Der Einsatz dieser Drop-Struktur stellt sicher, dass keine Handles verloren gehen und die Anwendung robust gegenüber Paniken wird. Eine andere Möglichkeit, die Verarbeitung von Threads direkt bei deren Abschluss zu gewährleisten, ist es, die Threads so zu gestalten, dass sie nicht nur ihren JoinHandle, sondern direkt ihre Resultate über einen Kanal senden.
So entfallen Joins größtenteils, und der Hauptthread empfängt die Resultate über den Kanal. Diese Lösung ist sehr elegant, sofern die zu bearbeitende Funktion bereits in der Form angepasst werden kann, dass sie einen Sender bekommt, über den sie ihr Ergebnis liefert. Für Nutzer, die den Quellcode der Ziel-Funktion nicht modifizieren können, stellt dies jedoch eine Einschränkung dar. Die explizite Behandlung von Paniken in dieser zweiten Herangehensweise geschieht mittels panic::catch_unwind. Dadurch lassen sich Paniken abfangen und dem Hauptthread auch als solche kommunizieren, was ein feingranulares Fehlerhandling ermöglicht.
Hier kann dann sowohl der Erfolgsfall als auch der Fehlerfall differenziert behandelt und protokolliert werden. Werden solche Mechanismen nicht berücksichtigt, kommen häufig Meldungen aus stdio, dass ein Thread panikiert sei, was zu unerwünschten Log-Einträgen und potenziellen Fehlerquellen führt. Die proaktive Gestaltungsweise mit catch_unwind und Drop-basierten SendOnDrop-Structs bietet deshalb den Vorteil, den Programmfluss kontrolliert und sauber zu halten. Darüber hinaus funktioniert das Konzept der „self-shipping threads“ nicht nur innerhalb von Scoped Threads. Es lässt sich auch auf reguläre, ungebundene Threads übertragen.
Die gleiche Logik findet Anwendung: Ein Kanal zur Kommunikation der JoinHandles wird erstellt, die dann vom ausgeführten Thread via Drop zurück an den Hauptthread gesendet werden, wodurch das Handling von Threads in klassischem Umfeld ebenso ermöglicht wird. Die Wahl zwischen beiden Vorgehensweisen ist stark kontextabhängig. Wenn die Funktion, die im Thread läuft, bereits so gestaltet ist, dass sie Ergebnisse über einen Kanal liefert, ist das direkte Ergebnis-über-Kanal-Senden einfach und elegant. Soll hingegen die Behandlung lediglich über JoinHandles erfolgen und man möchte den Rust-internen Thread-Unwinding-Mechanismus nutzen, empfiehlt sich der Ansatz mit self-shipping Threads und Drop zum sicheren Übermitteln der Handles. Zusammenfassend zeigen diese Strategien, wie komplexe Anforderungen an Multithreading in Rust lösbar sind, auch wenn die Standardbibliothek gewisse Komfortmethoden nicht mitbringt.